Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67e54c3316 | ||
|
|
7e1d752061 | ||
|
|
e4a1a25e77 | ||
|
|
aac81d6f29 | ||
|
|
3c5e5d007a | ||
|
|
8c47aa3a11 | ||
|
|
1f987be75a | ||
|
|
7a4008bd9c | ||
|
|
f4834f968a | ||
|
|
32ee3c302d | ||
|
|
f5650a98ec | ||
|
|
9c3b235382 | ||
|
|
da37b1be88 | ||
|
|
50a13447a4 | ||
|
|
ce34d2c75f | ||
|
|
d394ac454b | ||
|
|
f24720b087 | ||
|
|
71a628673d | ||
|
|
5f5aac5e3e | ||
|
|
e65883cc9e | ||
|
|
b19af1e8f3 | ||
|
|
2864c4a6c0 | ||
|
|
6d0dac256d | ||
|
|
8922111471 | ||
|
|
74e7c8e8d1 | ||
|
|
2f74b2b229 | ||
|
|
cb9598a786 | ||
|
|
fc73756308 | ||
|
|
3f436877ee | ||
|
|
812028e50d | ||
|
|
38cf1c82a1 | ||
|
|
fd0f2afe16 | ||
|
|
0f9977744a | ||
|
|
9f1c82fe05 | ||
|
|
419bb7e366 | ||
|
|
734ba68eed | ||
|
|
708f8bcd6f | ||
|
|
7009b24568 | ||
|
|
5b90667b4b | ||
|
|
dec11f0c01 | ||
|
|
0f1ded2269 | ||
|
|
2473a0213e | ||
|
|
1064c784d4 | ||
|
|
ed9eeb6262 | ||
|
|
e6f7f7297d | ||
|
|
93cc0b6eb0 | ||
|
|
6af5a4966f | ||
|
|
14388e8186 | ||
|
|
5cebbb1692 | ||
|
|
a0e705beec | ||
|
|
761ca83da5 | ||
|
|
48d0ae63bf | ||
|
|
44f81bbf5c | ||
|
|
a2ce907480 | ||
|
|
e4631e7486 | ||
|
|
015cb8a0cd | ||
|
|
53edb6fdef | ||
|
|
f79538f6b2 | ||
|
|
a3a218fef1 | ||
|
|
0c6c3b8c43 | ||
|
|
a47cc0e711 | ||
|
|
ac3d6e1784 | ||
|
|
adacd8944b | ||
|
|
ea58dab71c | ||
|
|
cf3a3ad910 | ||
|
|
8660c675b6 | ||
|
|
1f4d67dc77 | ||
|
|
b0e23cb50a | ||
|
|
1e886a705d | ||
|
|
19b5b44454 | ||
|
|
b95c811898 | ||
|
|
3a9f3b773e | ||
|
|
6776d9106f | ||
|
|
ada7de466a | ||
|
|
c91dd20c8c | ||
|
|
3b24f4560f | ||
|
|
973e639274 | ||
|
|
e78c44459e | ||
|
|
f8c66fcf63 | ||
|
|
a1def0f0f8 | ||
|
|
e0dec05885 | ||
|
|
8662aed565 | ||
|
|
cdfa1ac5b2 | ||
|
|
ffcdf5ee10 | ||
|
|
899c504d1f | ||
|
|
d82aa9d4b4 | ||
|
|
ae08382b81 | ||
|
|
b9f8008c2c | ||
|
|
d25cee3d8c | ||
|
|
48714cd98b | ||
|
|
1a2bf580cd | ||
|
|
2ca1ab2250 |
@@ -41,6 +41,9 @@ jobs:
|
||||
- name: Build healthcheck
|
||||
run: go build -o /dev/null ./cmd/healthcheck
|
||||
|
||||
- name: Build pocketbase
|
||||
run: go build -o /dev/null ./cmd/pocketbase
|
||||
|
||||
- name: Run tests
|
||||
run: go test -short -race -count=1 -timeout=60s ./...
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
# ── ui: type-check & build ────────────────────────────────────────────────────
|
||||
check-ui:
|
||||
name: Check ui
|
||||
name: Test UI
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -55,104 +55,9 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ui-build
|
||||
path: ui/build
|
||||
retention-days: 1
|
||||
|
||||
# ── ui: source map upload ─────────────────────────────────────────────────────
|
||||
# Commented out — re-enable when GlitchTip source map uploads are needed again.
|
||||
#
|
||||
# upload-sourcemaps:
|
||||
# name: Upload source maps
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: [check-ui]
|
||||
# steps:
|
||||
# - name: Compute release version (strip leading v)
|
||||
# id: ver
|
||||
# run: |
|
||||
# V="${{ gitea.ref_name }}"
|
||||
# echo "version=${V#v}" >> "$GITHUB_OUTPUT"
|
||||
#
|
||||
# - name: Download build artifacts
|
||||
# uses: actions/download-artifact@v3
|
||||
# with:
|
||||
# name: ui-build
|
||||
# path: build
|
||||
#
|
||||
# - name: Install sentry-cli
|
||||
# run: npm install -g @sentry/cli
|
||||
#
|
||||
# - name: Inject debug IDs into build artifacts
|
||||
# run: sentry-cli sourcemaps inject ./build
|
||||
# env:
|
||||
# SENTRY_URL: https://errors.libnovel.cc/
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
# SENTRY_ORG: libnovel
|
||||
# SENTRY_PROJECT: ui
|
||||
#
|
||||
# - name: Upload injected build (for docker-ui)
|
||||
# uses: actions/upload-artifact@v3
|
||||
# with:
|
||||
# name: ui-build-injected
|
||||
# path: build
|
||||
# retention-days: 1
|
||||
#
|
||||
# - name: Create GlitchTip release
|
||||
# run: sentry-cli releases new ${{ steps.ver.outputs.version }}
|
||||
# env:
|
||||
# SENTRY_URL: https://errors.libnovel.cc/
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
# SENTRY_ORG: libnovel
|
||||
# SENTRY_PROJECT: ui
|
||||
#
|
||||
# - name: Upload source maps to GlitchTip
|
||||
# run: sentry-cli sourcemaps upload ./build --release ${{ steps.ver.outputs.version }}
|
||||
# env:
|
||||
# SENTRY_URL: https://errors.libnovel.cc/
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
# SENTRY_ORG: libnovel
|
||||
# SENTRY_PROJECT: ui
|
||||
#
|
||||
# - name: Finalize GlitchTip release
|
||||
# run: sentry-cli releases finalize ${{ steps.ver.outputs.version }}
|
||||
# env:
|
||||
# SENTRY_URL: https://errors.libnovel.cc/
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
# SENTRY_ORG: libnovel
|
||||
# SENTRY_PROJECT: ui
|
||||
#
|
||||
# - name: Prune old GlitchTip releases (keep latest 10)
|
||||
# run: |
|
||||
# set -euo pipefail
|
||||
# KEEP=10
|
||||
# OLD=$(curl -sf \
|
||||
# -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
|
||||
# "$SENTRY_URL/api/0/organizations/$SENTRY_ORG/releases/?project=$SENTRY_PROJECT&per_page=100" \
|
||||
# | python3 -c "
|
||||
# import sys, json
|
||||
# releases = json.load(sys.stdin)
|
||||
# for r in releases[$KEEP:]:
|
||||
# print(r['version'])
|
||||
# " KEEP=$KEEP)
|
||||
# for ver in $OLD; do
|
||||
# echo "Deleting old release: $ver"
|
||||
# sentry-cli releases delete "$ver" || true
|
||||
# done
|
||||
# env:
|
||||
# SENTRY_URL: https://errors.libnovel.cc
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
# SENTRY_ORG: libnovel
|
||||
# SENTRY_PROJECT: ui
|
||||
|
||||
# ── docker: all images in one job (single login) ──────────────────────────────
|
||||
# backend, runner, ui, and caddy are built sequentially in one job so Docker
|
||||
# Hub only needs to be authenticated once. This also eliminates 3 redundant
|
||||
# checkout + setup-buildx + scheduler round-trips compared to separate jobs.
|
||||
# ── docker: build + push all images via docker bake ──────────────────────────
|
||||
docker:
|
||||
name: Docker
|
||||
name: Build and push images
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-backend, check-ui]
|
||||
steps:
|
||||
@@ -160,121 +65,112 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Single login — credential is written to ~/.docker/config.json and
|
||||
# reused by all subsequent build-push-action steps in this job.
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
# ── backend ──────────────────────────────────────────────────────────────
|
||||
- name: Docker meta / backend
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-backend
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push / backend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: backend
|
||||
target: backend
|
||||
push: true
|
||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta-backend.outputs.version }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-backend:latest
|
||||
cache-to: type=inline
|
||||
|
||||
# ── runner ───────────────────────────────────────────────────────────────
|
||||
- name: Docker meta / runner
|
||||
id: meta-runner
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-runner
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push / runner
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: backend
|
||||
target: runner
|
||||
push: true
|
||||
tags: ${{ steps.meta-runner.outputs.tags }}
|
||||
labels: ${{ steps.meta-runner.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta-runner.outputs.version }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-runner:latest
|
||||
cache-to: type=inline
|
||||
|
||||
# ── ui ───────────────────────────────────────────────────────────────────
|
||||
- name: Download ui build artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: ui-build
|
||||
path: ui/build
|
||||
|
||||
- name: Allow build/ into Docker context (override .dockerignore)
|
||||
- name: Compute version tags
|
||||
id: ver
|
||||
run: |
|
||||
grep -v '^build$' ui/.dockerignore > ui/.dockerignore.tmp
|
||||
mv ui/.dockerignore.tmp ui/.dockerignore
|
||||
V="${{ gitea.ref_name }}"
|
||||
VER="${V#v}"
|
||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "major_minor=$(echo "$VER" | cut -d. -f1-2)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker meta / ui
|
||||
id: meta-ui
|
||||
uses: docker/metadata-action@v5
|
||||
- name: Build and push all images
|
||||
uses: docker/bake-action@v6
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-ui
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
files: docker-bake.hcl
|
||||
set: |
|
||||
*.output=type=image,push=true
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
MAJOR_MINOR: ${{ steps.ver.outputs.major_minor }}
|
||||
COMMIT: ${{ gitea.sha }}
|
||||
BUILD_TIME: ${{ gitea.event.head_commit.timestamp }}
|
||||
|
||||
- name: Build and push / ui
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ui
|
||||
push: true
|
||||
tags: ${{ steps.meta-ui.outputs.tags }}
|
||||
labels: ${{ steps.meta-ui.outputs.labels }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta-ui.outputs.version }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
BUILD_TIME=${{ gitea.event.head_commit.timestamp }}
|
||||
PREBUILT=1
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest
|
||||
cache-to: type=inline
|
||||
# ── deploy: sync docker-compose.yml + restart prod ───────────────────────────
|
||||
# Runs after all images are pushed to Docker Hub.
|
||||
# Copies the compose file from the tagged commit to the server, pulls the new
|
||||
# images, and restarts only the services whose image or config changed.
|
||||
# --remove-orphans cleans up containers no longer defined in the compose file
|
||||
# (e.g. the now-removed pb-init container).
|
||||
#
|
||||
# Required Gitea secrets:
|
||||
# PROD_HOST — prod server IP or hostname
|
||||
# PROD_USER — SSH login user (typically root)
|
||||
# PROD_SSH_KEY — private key whose public half is in authorized_keys
|
||||
# PROD_SSH_KNOWN_HOSTS — output of: ssh-keyscan -H <PROD_HOST>
|
||||
deploy-prod:
|
||||
name: Deploy to prod
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# ── caddy ────────────────────────────────────────────────────────────────
|
||||
- name: Docker meta / caddy
|
||||
id: meta-caddy
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-caddy
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
- name: Install SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
printf '%s\n' "${{ secrets.PROD_SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Build and push / caddy
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: caddy
|
||||
push: true
|
||||
tags: ${{ steps.meta-caddy.outputs.tags }}
|
||||
labels: ${{ steps.meta-caddy.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-caddy:latest
|
||||
cache-to: type=inline
|
||||
- name: Copy docker-compose.yml to prod
|
||||
run: |
|
||||
scp -i ~/.ssh/deploy_key \
|
||||
docker-compose.yml \
|
||||
"${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }}:/opt/libnovel/docker-compose.yml"
|
||||
|
||||
- name: Pull new images and restart changed services
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key \
|
||||
"${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }}" \
|
||||
'set -euo pipefail
|
||||
cd /opt/libnovel
|
||||
doppler run -- docker compose pull backend runner ui caddy pocketbase
|
||||
doppler run -- docker compose up -d --no-deps --remove-orphans backend runner ui caddy pocketbase'
|
||||
|
||||
# ── deploy homelab runner ─────────────────────────────────────────────────────
|
||||
# Syncs the homelab runner compose file and restarts the runner service.
|
||||
#
|
||||
# Required Gitea secrets:
|
||||
# HOMELAB_HOST — homelab server IP (192.168.0.109)
|
||||
# HOMELAB_USER — SSH login user (typically root)
|
||||
# HOMELAB_SSH_KEY — private key whose public half is in authorized_keys
|
||||
# HOMELAB_SSH_KNOWN_HOSTS — output of: ssh-keyscan -H <HOMELAB_HOST>
|
||||
deploy-homelab:
|
||||
name: Deploy to homelab
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${{ secrets.HOMELAB_SSH_KEY }}" > ~/.ssh/homelab_key
|
||||
chmod 600 ~/.ssh/homelab_key
|
||||
|
||||
- name: Copy docker-compose.yml to homelab
|
||||
run: |
|
||||
scp -i ~/.ssh/homelab_key \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
homelab/docker-compose.yml \
|
||||
"${{ secrets.HOMELAB_USER }}@${{ secrets.HOMELAB_HOST }}:/opt/libnovel-runner/docker-compose.yml"
|
||||
|
||||
- name: Pull new runner image and restart
|
||||
run: |
|
||||
ssh -i ~/.ssh/homelab_key \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
"${{ secrets.HOMELAB_USER }}@${{ secrets.HOMELAB_HOST }}" \
|
||||
'set -euo pipefail
|
||||
cd /opt/libnovel-runner
|
||||
doppler run --project libnovel --config prd_homelab -- docker compose pull runner
|
||||
doppler run --project libnovel --config prd_homelab -- docker compose up -d runner'
|
||||
|
||||
# ── Gitea release ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,3 +28,4 @@ Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.opencode/
|
||||
|
||||
14
AGENTS.md
14
AGENTS.md
@@ -47,11 +47,21 @@ Sub-directories have their own `AGENTS.md` with deeper context (e.g. `ios/AGENTS
|
||||
- `release.yaml` — runs on `v*` tags (build Docker images, upload source maps, create Gitea release)
|
||||
- Secrets: `DOCKER_USER`, `DOCKER_TOKEN`, `GITEA_TOKEN`, `GLITCHTIP_AUTH_TOKEN`
|
||||
|
||||
### Git credentials
|
||||
|
||||
Credentials are embedded in the remote URL — no `HOME=/root` or credential helper needed for push:
|
||||
|
||||
```
|
||||
https://kamil:95782641Apple%24@gitea.kalekber.cc/kamil/libnovel.git
|
||||
```
|
||||
|
||||
All git commands still use `HOME=/root` prefix for consistency (picks up `/root/.gitconfig` for user name/email), but push auth works without it.
|
||||
|
||||
### Releasing a new version
|
||||
|
||||
```bash
|
||||
git tag v2.5.X -m "Short title\n\nOptional longer body"
|
||||
git push origin v2.5.X
|
||||
HOME=/root git tag v2.6.X -m "Short title"
|
||||
HOME=/root git push origin v3-cleanup --tags
|
||||
```
|
||||
|
||||
CI will build all Docker images, upload source maps to GlitchTip, and create a Gitea release automatically.
|
||||
|
||||
97
CLAUDE.md
Normal file
97
CLAUDE.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Environment / Secrets
|
||||
|
||||
All project environment variables are stored in **Doppler**. When you need to access any secret or env var (e.g. API tokens, database URLs, credentials), fetch them via:
|
||||
|
||||
```bash
|
||||
doppler run -- <command> # inject all secrets into a command
|
||||
doppler secrets get SECRET_NAME # inspect a specific secret
|
||||
```
|
||||
|
||||
Never use `.env` files. Do not ask the user to provide secrets manually — they are available via Doppler.
|
||||
|
||||
## Commands
|
||||
|
||||
### Docker (via `just` — the primary way to run services)
|
||||
All services use Doppler for secrets injection. The `just` commands handle this automatically.
|
||||
|
||||
```bash
|
||||
just up # Start all services in background
|
||||
just up-fg # Start all services, stream logs
|
||||
just down # Stop all services
|
||||
just down-volumes # Full reset (destructive — removes all volumes)
|
||||
just build # Rebuild all Docker images
|
||||
just build-svc backend # Rebuild a specific service
|
||||
just restart # Stop + rebuild + start
|
||||
just logs # Tail all logs
|
||||
just log backend # Tail a specific service
|
||||
just shell backend # Open shell in running container
|
||||
just init # One-shot init: MinIO buckets, PocketBase collections, Postgres
|
||||
```
|
||||
|
||||
### Backend (Go)
|
||||
```bash
|
||||
cd backend
|
||||
go vet ./...
|
||||
go test -short -race -count=1 -timeout=60s ./...
|
||||
go test -short -race -count=1 -run TestFoo ./internal/somepackage/
|
||||
go build ./cmd/backend
|
||||
go build ./cmd/runner
|
||||
```
|
||||
|
||||
### Frontend (SvelteKit)
|
||||
```bash
|
||||
cd ui
|
||||
npm run dev # Dev server at localhost:5173
|
||||
npm run build # Production build
|
||||
npm run check # svelte-check (type-check)
|
||||
npm run paraglide # Regenerate i18n messages (run after editing messages/*.json)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Three services communicate via PocketBase records and a Redis/Valkey task queue:
|
||||
|
||||
**Backend** (`backend/cmd/backend`) — HTTP REST API. Handles reads, enqueues tasks to Redis via Asynq, returns presigned MinIO URLs. Minimal processing; delegates heavy work to the runner.
|
||||
|
||||
**Runner** (`backend/cmd/runner`) — Asynq task worker. Processes scraping, TTS audio generation, AI text/image generation. Reads/writes PocketBase and MinIO directly.
|
||||
|
||||
**UI** (`ui/`) — SvelteKit 2 + Svelte 5 SSR app. Consumes the backend API. Uses Paraglide JS for i18n (5 locales).
|
||||
|
||||
### Data layer
|
||||
| Service | Role |
|
||||
|---------|------|
|
||||
| **PocketBase** (SQLite) | Auth, structured records (books, chapters, tasks, subscriptions) |
|
||||
| **MinIO** (S3-compatible) | Object storage — chapter text, audio files, images |
|
||||
| **Meilisearch** | Full-text search (runner indexes, backend reads) |
|
||||
| **Redis/Valkey** | Asynq task queue + presigned URL cache |
|
||||
|
||||
### Key backend packages
|
||||
- `internal/backend/` — HTTP handlers and server setup
|
||||
- `internal/runner/` — Task processor implementations
|
||||
- `internal/storage/` — Unified MinIO + PocketBase interface (all data access goes through here)
|
||||
- `internal/orchestrator/` — Task orchestration across services
|
||||
- `internal/taskqueue/` — Enqueue helpers (backend side)
|
||||
- `internal/asynqqueue/` — Asynq queue setup (runner side)
|
||||
- `internal/config/` — Environment variable loading (Doppler-injected at runtime, no .env files)
|
||||
- `internal/presigncache/` — Redis cache for MinIO presigned URLs
|
||||
|
||||
### UI routing conventions (SvelteKit)
|
||||
- `+page.svelte` / `+page.server.ts` — Page + server-side load
|
||||
- `+layout.svelte` / `+layout.server.ts` — Layouts
|
||||
- `routes/api/` — API routes (`+server.ts`)
|
||||
- `lib/audio.svelte.ts` — Client-side audio playback store (Svelte 5 runes)
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- **Svelte 5 runes only** — use `$state`, `$derived`, `$effect`; do not use Svelte 4 stores or reactive statements.
|
||||
- **Modern Go idioms** — structured logging via `log/slog`, OpenTelemetry tracing throughout.
|
||||
- **No direct MinIO/PocketBase client calls** outside the `internal/storage/` package.
|
||||
- **Secrets via Doppler** — never use `.env` files. All secrets are injected by Doppler CLI.
|
||||
- **CI/CD is Gitea Actions** (`.gitea/workflows/`), not GitHub Actions. Use `gitea.ref_name`/`gitea.sha` variables.
|
||||
- **Git hooks** in `.githooks/` — enable with `just setup`.
|
||||
- **i18n**: translation files live in `ui/messages/{en,es,fr,de,pt}.json`; run `npm run paraglide` after editing them.
|
||||
- **Error tracking**: GlitchTip with per-service DSNs (backend id/2, runner id/3, UI id/1) stored in Doppler.
|
||||
225
DOCKERFILE_ANALYSIS.md
Normal file
225
DOCKERFILE_ANALYSIS.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# Dockerfile Dependency Analysis
|
||||
|
||||
## Current Image Sizes
|
||||
|
||||
| Image | Size | Status |
|
||||
|-------|------|--------|
|
||||
| backend | 179MB | ✅ Good |
|
||||
| runner | 178MB | ✅ Good |
|
||||
| pocketbase | 37MB | ✅ Excellent |
|
||||
| caddy | 114MB | ✅ Good |
|
||||
| ui | **413MB** | ⚠️ **LARGE** |
|
||||
|
||||
---
|
||||
|
||||
## UI Dependencies Analysis (413MB image)
|
||||
|
||||
### Production Dependencies (package.json)
|
||||
|
||||
| Package | Used? | Size Impact | Notes |
|
||||
|---------|-------|-------------|-------|
|
||||
| `@aws-sdk/client-s3` | ❌ **UNUSED** | ~100MB | **REMOVE** - Not imported anywhere |
|
||||
| `@aws-sdk/s3-request-presigner` | ❌ **UNUSED** | ~50MB | **REMOVE** - Not imported anywhere |
|
||||
| `@grafana/faro-web-sdk` | ✅ Used | ~2MB | Keep - RUM tracking |
|
||||
| `@inlang/paraglide-js` | ✅ Used | ~1MB | Keep - i18n |
|
||||
| `@opentelemetry/*` (5 packages) | ✅ Used | ~15MB | Keep - Server-side tracing |
|
||||
| `@sentry/sveltekit` | ✅ Used | ~10MB | Keep - Error tracking |
|
||||
| `cropperjs` | ✅ Used | ~500KB | Keep - Avatar cropping |
|
||||
| `ioredis` | ✅ Used | ~5MB | Keep - Redis client (server-side) |
|
||||
| `marked` | ✅ Used | ~500KB | Keep - Markdown parsing |
|
||||
| `pocketbase` | ✅ Used | ~200KB | Keep - PocketBase client |
|
||||
| **EXTRANEOUS** | | | |
|
||||
| `@playwright/test` | ❌ **EXTRANEOUS** | ~50MB | **REMOVE** - Should be devDependency |
|
||||
| `playwright-core` | ❌ **EXTRANEOUS** | ~50MB | **REMOVE** - Should be devDependency |
|
||||
| `playwright` | ❌ **EXTRANEOUS** | ~50MB | **REMOVE** - Should be devDependency |
|
||||
|
||||
**Total waste: ~300MB (AWS SDK + Playwright)**
|
||||
|
||||
### Why AWS SDK is in dependencies?
|
||||
|
||||
Checking git history... it was likely added for direct S3 uploads but never actually used. The backend handles all S3 operations.
|
||||
|
||||
---
|
||||
|
||||
## Backend Dependencies Analysis (179MB image)
|
||||
|
||||
### Docker Image Breakdown
|
||||
|
||||
```dockerfile
|
||||
FROM alpine:3.21 # ~7MB base
|
||||
RUN apk add ffmpeg # ~40MB (needed for audio transcoding)
|
||||
RUN apk add ca-certificates # ~200KB (needed for HTTPS)
|
||||
COPY /out/backend # ~130MB (Go binary + stdlib)
|
||||
```
|
||||
|
||||
**All dependencies justified:**
|
||||
- ✅ `ffmpeg` - Required for pocket-tts WAV→MP3 transcoding
|
||||
- ✅ `ca-certificates` - Required for HTTPS connections to external services
|
||||
- ✅ Go binary includes all dependencies (static linking)
|
||||
|
||||
### Go Module Analysis
|
||||
|
||||
```bash
|
||||
go list -m all | wc -l # 169 modules
|
||||
```
|
||||
|
||||
Go binaries are statically linked, so unused imports don't increase image size. The build process with `-ldflags="-s -w"` strips symbols and debug info.
|
||||
|
||||
**Optimization already applied:** ✅
|
||||
- CGO_ENABLED=0 (static linking, no libc dependency)
|
||||
- -ldflags="-s -w" (strip symbols, ~20% size reduction)
|
||||
- BuildKit cache mounts (faster rebuilds)
|
||||
|
||||
---
|
||||
|
||||
## Caddy Dependencies Analysis (114MB image)
|
||||
|
||||
```dockerfile
|
||||
FROM caddy:2-alpine # ~50MB base
|
||||
COPY /usr/bin/caddy # ~60MB (with 3 plugins)
|
||||
COPY errors/ # ~4MB (error page assets)
|
||||
```
|
||||
|
||||
**Plugins in use:**
|
||||
- ✅ `caddy-ratelimit` - Used for API rate limiting
|
||||
- ✅ `caddy-crowdsec-bouncer` - Used for CrowdSec integration
|
||||
- ✅ `caddy-l4` - Used for TCP/UDP proxying (Redis TLS proxy)
|
||||
|
||||
**All plugins justified** - actively used in production Caddyfile.
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### 1. Remove AWS SDK from UI (PRIORITY: HIGH)
|
||||
|
||||
**Impact:** ~150MB reduction (413MB → 263MB, 36% smaller)
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
npm uninstall @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
|
||||
```
|
||||
|
||||
**Risk:** None - confirmed unused via grep
|
||||
|
||||
### 2. Remove Playwright from production (PRIORITY: HIGH)
|
||||
|
||||
**Impact:** ~150MB reduction (263MB → 113MB, 57% smaller)
|
||||
|
||||
**Issue:** Playwright packages are marked as "extraneous" - they're installed but not in package.json. This happens when:
|
||||
- Someone ran `npm install playwright` without `--save-dev`
|
||||
- package-lock.json got corrupted
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
cd ui
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
This will regenerate package-lock.json without the extraneous packages.
|
||||
|
||||
### 3. Consider distroless for backend/runner (OPTIONAL)
|
||||
|
||||
**Impact:** ~10-15MB reduction per image
|
||||
|
||||
**Current:** Alpine + ffmpeg (required)
|
||||
**Alternative:** Use distroless + statically compiled ffmpeg
|
||||
|
||||
**Tradeoff:**
|
||||
- Pros: Smaller attack surface, smaller image
|
||||
- Cons: Harder to debug, need to bundle ffmpeg binary
|
||||
- Verdict: **NOT WORTH IT** - ffmpeg from apk is well-maintained
|
||||
|
||||
### 4. Use .dockerignore (ALREADY GOOD ✅)
|
||||
|
||||
Both UI and backend have proper .dockerignore files:
|
||||
- ✅ node_modules excluded (ui)
|
||||
- ✅ build artifacts excluded
|
||||
- ✅ .git excluded
|
||||
|
||||
---
|
||||
|
||||
## Expected Results After Cleanup
|
||||
|
||||
| Image | Before | After | Savings |
|
||||
|-------|--------|-------|---------|
|
||||
| backend | 179MB | 179MB | 0MB (already optimal) |
|
||||
| runner | 178MB | 178MB | 0MB (already optimal) |
|
||||
| pocketbase | 37MB | 37MB | 0MB (already optimal) |
|
||||
| caddy | 114MB | 114MB | 0MB (already optimal) |
|
||||
| ui | **413MB** | **~110MB** | **~300MB (73% smaller)** |
|
||||
|
||||
**Total deployment size reduction:** ~300MB
|
||||
**Deployment time improvement:** ~20-30s faster (less to pull from Docker Hub)
|
||||
|
||||
---
|
||||
|
||||
## Action Plan
|
||||
|
||||
```bash
|
||||
# 1. Clean up UI dependencies
|
||||
cd ui
|
||||
npm uninstall @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
|
||||
# 2. Verify no imports remain
|
||||
grep -r "@aws-sdk" src/ # Should return nothing
|
||||
grep -r "playwright" src/ # Should return nothing
|
||||
|
||||
# 3. Test build locally
|
||||
npm run build
|
||||
|
||||
# 4. Commit changes
|
||||
git add package.json package-lock.json
|
||||
git commit -m "chore: remove unused AWS SDK and Playwright dependencies from UI
|
||||
|
||||
- Remove @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner (~150MB)
|
||||
- Remove extraneous Playwright packages (~150MB)
|
||||
- UI image size: 413MB → ~110MB (73% smaller)
|
||||
|
||||
All S3 operations are handled by the backend, not the UI."
|
||||
|
||||
# 5. Tag and deploy
|
||||
git tag v4.3.7 -m "chore: remove unused dependencies, reduce UI image by 73%"
|
||||
git push origin --tags
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Backend/Runner Go Dependencies (For Reference)
|
||||
|
||||
The Go images are already well-optimized. Here are the main dependencies:
|
||||
|
||||
**Backend (179MB):**
|
||||
- PocketBase SDK
|
||||
- MinIO SDK (S3)
|
||||
- Meilisearch SDK
|
||||
- Redis SDK (ioredis equivalent)
|
||||
- HTTP router (chi)
|
||||
- OpenTelemetry SDK
|
||||
|
||||
**Runner (178MB):**
|
||||
- Same as backend
|
||||
- + Chromedp (headless Chrome for scraping)
|
||||
- + Audio processing libs
|
||||
|
||||
All are actively used - no dead code found.
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Current state:**
|
||||
- Backend, runner, pocketbase, caddy: ✅ Already well-optimized
|
||||
- UI: ⚠️ Carrying 300MB of unused dependencies
|
||||
|
||||
**Impact of cleanup:**
|
||||
- 73% smaller UI image
|
||||
- Faster deployments
|
||||
- Lower bandwidth costs
|
||||
- Cleaner dependency tree
|
||||
|
||||
**Effort:** ~5 minutes (remove 2 packages + regenerate lockfile)
|
||||
**Risk:** Very low (confirmed unused via code search)
|
||||
60
HOMELAB_SECRETS_SETUP.md
Normal file
60
HOMELAB_SECRETS_SETUP.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Homelab Deployment Secrets Setup
|
||||
|
||||
The release workflow now includes automatic deployment to the homelab runner server. You need to add these secrets to Gitea.
|
||||
|
||||
## Required Secrets
|
||||
|
||||
Go to: `https://gitea.kalekber.cc/kamil/libnovel/settings/secrets/actions`
|
||||
|
||||
### 1. HOMELAB_HOST
|
||||
```
|
||||
192.168.0.109
|
||||
```
|
||||
|
||||
### 2. HOMELAB_USER
|
||||
```
|
||||
root
|
||||
```
|
||||
|
||||
### 3. HOMELAB_SSH_KEY
|
||||
|
||||
If you want to use the same SSH key as prod:
|
||||
- Copy the value from `PROD_SSH_KEY` secret
|
||||
|
||||
If you want a separate key:
|
||||
```bash
|
||||
# On your local machine or CI runner
|
||||
cat ~/.ssh/id_rsa # or your preferred key
|
||||
```
|
||||
|
||||
### 4. HOMELAB_SSH_KNOWN_HOSTS
|
||||
|
||||
Run this when the homelab server is reachable:
|
||||
```bash
|
||||
ssh-keyscan -H 192.168.0.109 2>/dev/null
|
||||
```
|
||||
|
||||
Expected output format:
|
||||
```
|
||||
|1|base64hash...|192.168.0.109 ssh-rsa AAAAB3NzaC...
|
||||
|1|base64hash...|192.168.0.109 ecdsa-sha2-nistp256 AAAAE2...
|
||||
|1|base64hash...|192.168.0.109 ssh-ed25519 AAAAC3...
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
After adding the secrets, the next release (e.g., v4.1.10) will automatically:
|
||||
1. Build all Docker images
|
||||
2. Deploy to prod (165.22.70.138) ✅
|
||||
3. Deploy to homelab (192.168.0.109) ✅ NEW
|
||||
4. Create a Gitea release
|
||||
|
||||
Both deployments run in parallel for faster releases.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the homelab deployment fails:
|
||||
- Check that the secrets are set correctly
|
||||
- Verify SSH access: `ssh root@192.168.0.109`
|
||||
- Check Doppler config exists: `doppler configs --project libnovel`
|
||||
- Manually test: `cd /opt/libnovel-runner && doppler run --project libnovel --config prd_homelab -- docker compose pull runner`
|
||||
@@ -27,7 +27,10 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
-o /out/runner ./cmd/runner && \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w" \
|
||||
-o /out/healthcheck ./cmd/healthcheck
|
||||
-o /out/healthcheck ./cmd/healthcheck && \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w" \
|
||||
-o /out/pocketbase ./cmd/pocketbase
|
||||
|
||||
# ── backend service ──────────────────────────────────────────────────────────
|
||||
# Uses Alpine (not distroless) so ffmpeg is available for on-demand voice
|
||||
@@ -40,6 +43,18 @@ COPY --from=builder /out/backend /backend
|
||||
USER appuser
|
||||
ENTRYPOINT ["/backend"]
|
||||
|
||||
# ── pocketbase service ───────────────────────────────────────────────────────
|
||||
# Runs the custom PocketBase binary with Go migrations baked in.
|
||||
# On every `serve` startup it applies any pending migrations automatically.
|
||||
# Data is stored in /pb_data (mounted as a Docker volume in production).
|
||||
FROM alpine:3.21 AS pocketbase
|
||||
RUN apk add --no-cache ca-certificates wget
|
||||
COPY --from=builder /out/pocketbase /pocketbase
|
||||
RUN mkdir -p /pb_data
|
||||
VOLUME /pb_data
|
||||
EXPOSE 8090
|
||||
CMD ["/pocketbase", "serve", "--dir", "/pb_data", "--http", "0.0.0.0:8090"]
|
||||
|
||||
# ── runner service ───────────────────────────────────────────────────────────
|
||||
# Uses Alpine (not distroless) so ffmpeg is available for WAV→MP3 transcoding
|
||||
# when pocket-tts voices are used.
|
||||
|
||||
@@ -177,6 +177,7 @@ func run() error {
|
||||
DefaultVoice: cfg.Kokoro.DefaultVoice,
|
||||
Version: version,
|
||||
Commit: commit,
|
||||
AdminToken: cfg.HTTP.AdminToken,
|
||||
},
|
||||
backend.Dependencies{
|
||||
BookReader: store,
|
||||
@@ -189,6 +190,7 @@ func run() error {
|
||||
ChapterImageStore: store,
|
||||
Producer: producer,
|
||||
TaskReader: store,
|
||||
ImportFileStore: store,
|
||||
SearchIndex: searchIndex,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
@@ -197,6 +199,8 @@ func run() error {
|
||||
TextGen: textGenClient,
|
||||
BookWriter: store,
|
||||
AIJobStore: store,
|
||||
BookAdminStore: store,
|
||||
NotificationStore: store,
|
||||
Log: log,
|
||||
},
|
||||
)
|
||||
|
||||
47
backend/cmd/pocketbase/main.go
Normal file
47
backend/cmd/pocketbase/main.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Command pocketbase is a thin wrapper that runs PocketBase as a Go framework
|
||||
// with version-controlled Go migrations.
|
||||
//
|
||||
// On every `serve`, PocketBase automatically applies any pending migrations from
|
||||
// the migrations/ package before accepting traffic.
|
||||
//
|
||||
// Usage (Docker):
|
||||
//
|
||||
// ./pocketbase serve --dir /pb_data --http 0.0.0.0:8090
|
||||
//
|
||||
// Migration workflow:
|
||||
//
|
||||
// # Generate a timestamped stub:
|
||||
// go run ./cmd/pocketbase migrate create "description"
|
||||
// # Apply manually (also runs automatically on serve):
|
||||
// go run ./cmd/pocketbase migrate up
|
||||
// # Revert last migration:
|
||||
// go run ./cmd/pocketbase migrate down 1
|
||||
// # After migrating an existing install, mark existing schema as done:
|
||||
// go run ./cmd/pocketbase migrate history-sync
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||
|
||||
// Register all migrations via init().
|
||||
_ "github.com/libnovel/backend/migrations"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := pocketbase.New()
|
||||
|
||||
// Register the migrate sub-command.
|
||||
// Automigrate: false — migrations are written by hand, never auto-generated
|
||||
// from Admin UI changes. Pending migrations still apply automatically on
|
||||
// every `serve` regardless of this flag.
|
||||
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
|
||||
Automigrate: false,
|
||||
})
|
||||
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"github.com/libnovel/backend/internal/runner"
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
"github.com/libnovel/backend/internal/taskqueue"
|
||||
"github.com/libnovel/backend/internal/webpush"
|
||||
)
|
||||
|
||||
// version and commit are set at build time via -ldflags.
|
||||
@@ -190,23 +191,35 @@ func run() error {
|
||||
log.Info("runner: poll mode — using PocketBase for task dispatch")
|
||||
}
|
||||
|
||||
// ── Web Push ─────────────────────────────────────────────────────────────
|
||||
var pushSender *webpush.Sender
|
||||
if cfg.VAPID.PublicKey != "" && cfg.VAPID.PrivateKey != "" {
|
||||
pushSender = webpush.New(cfg.VAPID.PublicKey, cfg.VAPID.PrivateKey, cfg.VAPID.Subject, log)
|
||||
log.Info("runner: web push notifications enabled")
|
||||
} else {
|
||||
log.Info("runner: VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY not set — push notifications disabled")
|
||||
}
|
||||
|
||||
deps := runner.Dependencies{
|
||||
Consumer: consumer,
|
||||
BookWriter: store,
|
||||
BookReader: store,
|
||||
AudioStore: store,
|
||||
CoverStore: store,
|
||||
TranslationStore: store,
|
||||
BookImport: storage.NewBookImporter(store),
|
||||
ChapterIngester: store,
|
||||
SearchIndex: searchIndex,
|
||||
Novel: novel,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
CFAI: cfaiClient,
|
||||
LibreTranslate: ltClient,
|
||||
Notifier: store,
|
||||
Log: log,
|
||||
BookWriter: store,
|
||||
BookReader: store,
|
||||
AudioStore: store,
|
||||
CoverStore: store,
|
||||
TranslationStore: store,
|
||||
BookImport: storage.NewBookImporter(store),
|
||||
ImportChapterStore: store,
|
||||
ChapterIngester: store,
|
||||
SearchIndex: searchIndex,
|
||||
Novel: novel,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
CFAI: cfaiClient,
|
||||
LibreTranslate: ltClient,
|
||||
Notifier: store,
|
||||
WebPush: pushSender,
|
||||
Store: store,
|
||||
Log: log,
|
||||
}
|
||||
r := runner.New(rCfg, deps)
|
||||
|
||||
|
||||
@@ -3,70 +3,99 @@ module github.com/libnovel/backend
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/getsentry/sentry-go v0.43.0
|
||||
github.com/hibiken/asynq v0.26.0
|
||||
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d
|
||||
github.com/meilisearch/meilisearch-go v0.36.1
|
||||
github.com/minio/minio-go/v7 v7.0.98
|
||||
golang.org/x/net v0.51.0
|
||||
github.com/pdfcpu/pdfcpu v0.11.1
|
||||
github.com/pocketbase/pocketbase v0.36.9
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0
|
||||
go.opentelemetry.io/otel v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0
|
||||
go.opentelemetry.io/otel/log v0.18.0
|
||||
go.opentelemetry.io/otel/sdk v1.42.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0
|
||||
golang.org/x/net v0.52.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/getsentry/sentry-go v0.43.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/hibiken/asynq v0.26.0 // indirect
|
||||
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d // indirect
|
||||
github.com/hhrutter/lzw v1.0.0 // indirect
|
||||
github.com/hhrutter/pkcs7 v0.2.0 // indirect
|
||||
github.com/hhrutter/tiff v1.0.2 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/meilisearch/meilisearch-go v0.36.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pocketbase/dbx v1.12.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/image v0.38.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/grpc v1.79.2 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.48.2 // indirect
|
||||
)
|
||||
|
||||
235
backend/go.sum
235
backend/go.sum
@@ -1,21 +1,48 @@
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||
github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4=
|
||||
github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -23,16 +50,39 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg=
|
||||
github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
|
||||
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
|
||||
github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I=
|
||||
github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE=
|
||||
github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8=
|
||||
github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw=
|
||||
github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw=
|
||||
github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58=
|
||||
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d h1:Ld5m8EIK5QVOq/owOexKIbETij3skACg4eU1pArHsrw=
|
||||
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d/go.mod h1:hhpStehaxSGg3ib9wJXzw5AXY1YS6lQ9BNavAgPbIhE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
@@ -40,6 +90,18 @@ github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4O
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/meilisearch/meilisearch-go v0.36.1 h1:mJTCJE5g7tRvaqKco6DfqOuJEjX+rRltDEnkEC02Y0M=
|
||||
github.com/meilisearch/meilisearch-go v0.36.1/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
@@ -50,42 +112,61 @@ github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRi
|
||||
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas=
|
||||
github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pocketbase/pocketbase v0.36.9 h1:x3mXMB4AwhTzJ34JZpZR7IQyUih7Fx1l86r0V/k4oW8=
|
||||
github.com/pocketbase/pocketbase v0.36.9/go.mod h1:t3sMcAxGHrDAXNcZ+65cZxBMpFP1vBdI9DrghB4n5Gw=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k=
|
||||
@@ -108,26 +189,111 @@ go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXY
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
|
||||
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
@@ -136,8 +302,39 @@ google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
|
||||
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/taskqueue"
|
||||
)
|
||||
|
||||
@@ -88,18 +89,19 @@ func (p *Producer) CreateTranslationTask(ctx context.Context, slug string, chapt
|
||||
}
|
||||
|
||||
// CreateImportTask creates a PocketBase record then enqueues an Asynq job for PDF/EPUB import.
|
||||
func (p *Producer) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error) {
|
||||
id, err := p.pb.CreateImportTask(ctx, slug, title, fileType, objectKey, initiatorUserID)
|
||||
func (p *Producer) CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error) {
|
||||
id, err := p.pb.CreateImportTask(ctx, task)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
payload := ImportPayload{
|
||||
PBTaskID: id,
|
||||
Slug: slug,
|
||||
Title: title,
|
||||
FileType: fileType,
|
||||
ObjectKey: objectKey,
|
||||
PBTaskID: id,
|
||||
Slug: task.Slug,
|
||||
Title: task.Title,
|
||||
FileType: task.FileType,
|
||||
ObjectKey: task.ObjectKey,
|
||||
ChaptersKey: task.ChaptersKey,
|
||||
}
|
||||
if err := p.enqueue(ctx, TypeImportBook, payload); err != nil {
|
||||
// Non-fatal: PB record exists; runner will pick it up on next poll.
|
||||
|
||||
@@ -48,9 +48,10 @@ type ScrapePayload struct {
|
||||
|
||||
// ImportPayload is the Asynq job payload for PDF/EPUB import tasks.
|
||||
type ImportPayload struct {
|
||||
PBTaskID string `json:"pb_task_id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
FileType string `json:"file_type"` // "pdf" or "epub"
|
||||
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
|
||||
PBTaskID string `json:"pb_task_id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
FileType string `json:"file_type"` // "pdf" or "epub"
|
||||
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
|
||||
ChaptersKey string `json:"chapters_key"` // MinIO path to pre-parsed chapters JSON
|
||||
}
|
||||
|
||||
@@ -293,7 +293,8 @@ func (s *Server) handleGetRanking(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleGetCover handles GET /api/cover/{domain}/{slug}.
|
||||
// Serves the cover image directly from MinIO when available; falls back to a
|
||||
// redirect to the novelfire CDN when the cover has not yet been downloaded.
|
||||
// redirect to the stored cover URL from PocketBase when the cover has not yet
|
||||
// been downloaded to MinIO.
|
||||
func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
@@ -318,10 +319,20 @@ func (s *Server) handleGetCover(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: redirect to the CDN. The caller sees a working image; the
|
||||
// cover will be populated on the next catalogue refresh run.
|
||||
coverURL := fmt.Sprintf("https://cdn.novelfire.net/covers/%s.jpg", slug)
|
||||
http.Redirect(w, r, coverURL, http.StatusFound)
|
||||
// Fallback: read the stored cover URL from PocketBase and redirect to it.
|
||||
// This avoids the broken cdn.novelfire.net domain and uses the actual URL
|
||||
// scraped from the source. If the book is not found, return 404.
|
||||
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug)
|
||||
if err != nil {
|
||||
s.deps.Log.Warn("handleGetCover: ReadMetadata error", "slug", slug, "err", err)
|
||||
http.Error(w, "cover not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !ok || meta.Cover == "" || strings.HasPrefix(meta.Cover, "/api/cover/") {
|
||||
http.Error(w, "cover not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, meta.Cover, http.StatusFound)
|
||||
}
|
||||
|
||||
// ── Preview (live scrape, no store writes) ─────────────────────────────────────
|
||||
@@ -1279,6 +1290,10 @@ func (s *Server) handleTranslationRead(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Translated chapter content is immutable once generated — cache aggressively.
|
||||
// The browser and any intermediary (CDN, SvelteKit fetch cache) can reuse this
|
||||
// response for 1 hour without hitting MinIO again.
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400")
|
||||
writeJSON(w, 0, map[string]string{"html": buf.String(), "lang": lang})
|
||||
}
|
||||
|
||||
@@ -1865,13 +1880,19 @@ func (s *Server) handleCatalogue(w http.ResponseWriter, r *http.Request) {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// Admin users (identified by bearer token) see all non-archived books
|
||||
// including those marked admin_only.
|
||||
isAdmin := s.cfg.AdminToken != "" &&
|
||||
r.Header.Get("Authorization") == "Bearer "+s.cfg.AdminToken
|
||||
|
||||
cq := meili.CatalogueQuery{
|
||||
Q: q.Get("q"),
|
||||
Genre: genre,
|
||||
Status: status,
|
||||
Sort: sort,
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
Q: q.Get("q"),
|
||||
Genre: genre,
|
||||
Status: status,
|
||||
Sort: sort,
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
AdminAll: isAdmin,
|
||||
}
|
||||
|
||||
books, total, facets, err := s.deps.SearchIndex.Catalogue(r.Context(), cq)
|
||||
@@ -1881,6 +1902,16 @@ func (s *Server) handleCatalogue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Rewrite raw scraped cover URLs to go through the backend cover proxy.
|
||||
// /api/cover/{domain}/{slug} serves from MinIO when available, otherwise
|
||||
// redirects to the CDN. This avoids ERR_BLOCKED_BY_ORB when the source
|
||||
// site returns HTML error pages instead of images.
|
||||
for i := range books {
|
||||
if !strings.HasPrefix(books[i].Cover, "/api/cover/") {
|
||||
books[i].Cover = fmt.Sprintf("/api/cover/novelfire.net/%s", books[i].Slug)
|
||||
}
|
||||
}
|
||||
|
||||
hasNext := int64(page*limit) < total
|
||||
|
||||
w.Header().Set("Cache-Control", "public, max-age=60")
|
||||
|
||||
117
backend/internal/backend/handlers_books_admin.go
Normal file
117
backend/internal/backend/handlers_books_admin.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
// handleAdminArchiveBook handles PATCH /api/admin/books/{slug}/archive.
|
||||
// Soft-deletes a book by setting archived=true in PocketBase and updating the
|
||||
// Meilisearch document so it is excluded from all public search results.
|
||||
// The book data is preserved and can be restored with the unarchive endpoint.
|
||||
func (s *Server) handleAdminArchiveBook(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
if s.deps.BookAdminStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deps.BookAdminStore.ArchiveBook(r.Context(), slug); err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "book not found")
|
||||
return
|
||||
}
|
||||
s.deps.Log.Error("archive book failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Update the Meilisearch document so the archived flag takes effect
|
||||
// immediately in search/catalogue results.
|
||||
if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok {
|
||||
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
|
||||
s.deps.Log.Warn("archive book: meili upsert failed", "slug", slug, "err", upsertErr)
|
||||
}
|
||||
}
|
||||
|
||||
s.deps.Log.Info("book archived", "slug", slug)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "status": "archived"})
|
||||
}
|
||||
|
||||
// handleAdminUnarchiveBook handles PATCH /api/admin/books/{slug}/unarchive.
|
||||
// Restores a previously archived book by clearing the archived flag, making it
|
||||
// publicly visible in search and catalogue results again.
|
||||
func (s *Server) handleAdminUnarchiveBook(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
if s.deps.BookAdminStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deps.BookAdminStore.UnarchiveBook(r.Context(), slug); err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "book not found")
|
||||
return
|
||||
}
|
||||
s.deps.Log.Error("unarchive book failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Sync the updated archived=false state back to Meilisearch.
|
||||
if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok {
|
||||
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
|
||||
s.deps.Log.Warn("unarchive book: meili upsert failed", "slug", slug, "err", upsertErr)
|
||||
}
|
||||
}
|
||||
|
||||
s.deps.Log.Info("book unarchived", "slug", slug)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "status": "active"})
|
||||
}
|
||||
|
||||
// handleAdminDeleteBook handles DELETE /api/admin/books/{slug}.
|
||||
// Permanently removes all data for a book:
|
||||
// - PocketBase books record and all chapters_idx records
|
||||
// - All MinIO chapter markdown objects and the cover image
|
||||
// - Meilisearch document
|
||||
//
|
||||
// This operation is irreversible. Use the archive endpoint for soft-deletion.
|
||||
func (s *Server) handleAdminDeleteBook(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
if s.deps.BookAdminStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.deps.BookAdminStore.DeleteBook(r.Context(), slug); err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "book not found")
|
||||
return
|
||||
}
|
||||
s.deps.Log.Error("delete book failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Remove from Meilisearch — best-effort (log on failure, don't fail request).
|
||||
if err := s.deps.SearchIndex.DeleteBook(r.Context(), slug); err != nil {
|
||||
s.deps.Log.Warn("delete book: meili delete failed", "slug", slug, "err", err)
|
||||
}
|
||||
|
||||
s.deps.Log.Info("book deleted", "slug", slug)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "status": "deleted"})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -9,14 +10,20 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
type importRequest struct {
|
||||
Title string `json:"title"`
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"` // "pdf" or "epub"
|
||||
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Genres []string `json:"genres"`
|
||||
Summary string `json:"summary"`
|
||||
BookStatus string `json:"book_status"` // "ongoing" | "completed" | "hiatus"
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"` // "pdf" or "epub"
|
||||
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
|
||||
}
|
||||
|
||||
type importResponse struct {
|
||||
@@ -39,6 +46,8 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
var req importRequest
|
||||
var objectKey string
|
||||
var chaptersKey string
|
||||
var chapterCount int
|
||||
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
@@ -46,6 +55,17 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
req.Title = r.FormValue("title")
|
||||
req.Author = r.FormValue("author")
|
||||
req.CoverURL = r.FormValue("cover_url")
|
||||
req.Summary = r.FormValue("summary")
|
||||
req.BookStatus = r.FormValue("book_status")
|
||||
if g := r.FormValue("genres"); g != "" {
|
||||
for _, s := range strings.Split(g, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
req.Genres = append(req.Genres, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
req.FileName = r.FormValue("file_name")
|
||||
req.FileType = r.FormValue("file_type")
|
||||
analyzeOnly := r.FormValue("analyze") == "true"
|
||||
@@ -79,17 +99,38 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Upload to MinIO for actual import
|
||||
// Parse PDF/EPUB on the backend (with timeout) and store chapters as JSON.
|
||||
// The runner only needs to ingest pre-parsed chapters — no PDF parsing on runner.
|
||||
parseCtx, parseCancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||
defer parseCancel()
|
||||
chapters, parseErr := storage.ParseImportFile(parseCtx, data, req.FileType)
|
||||
if parseErr != nil || len(chapters) == 0 {
|
||||
jsonError(w, http.StatusUnprocessableEntity, "could not parse file: "+func() string {
|
||||
if parseErr != nil { return parseErr.Error() }
|
||||
return "no chapters found"
|
||||
}())
|
||||
return
|
||||
}
|
||||
|
||||
// Store raw file in MinIO (for reference/re-import).
|
||||
objectKey = fmt.Sprintf("imports/%d_%s", time.Now().Unix(), header.Filename)
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
if s.deps.ImportFileStore == nil {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
return
|
||||
}
|
||||
if err := store.PutImportFile(r.Context(), objectKey, data); err != nil {
|
||||
if err := s.deps.ImportFileStore.PutImportFile(r.Context(), objectKey, data); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "upload file: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Store pre-parsed chapters JSON in MinIO so runner can ingest without re-parsing.
|
||||
chaptersJSON, _ := json.Marshal(chapters)
|
||||
chaptersKey = fmt.Sprintf("imports/%d_%s_chapters.json", time.Now().Unix(), strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)))
|
||||
if err := s.deps.ImportFileStore.PutImportChapters(r.Context(), chaptersKey, chaptersJSON); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "store chapters: "+err.Error())
|
||||
return
|
||||
}
|
||||
chapterCount = len(chapters)
|
||||
} else {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
@@ -115,28 +156,43 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
|
||||
return -1
|
||||
}, slug)
|
||||
|
||||
taskID, err := s.deps.Producer.CreateImportTask(r.Context(), slug, req.Title, req.FileType, objectKey, "")
|
||||
taskID, err := s.deps.Producer.CreateImportTask(r.Context(), domain.ImportTask{
|
||||
Slug: slug,
|
||||
Title: req.Title,
|
||||
Author: req.Author,
|
||||
CoverURL: req.CoverURL,
|
||||
Genres: req.Genres,
|
||||
Summary: req.Summary,
|
||||
BookStatus: req.BookStatus,
|
||||
FileType: req.FileType,
|
||||
ObjectKey: objectKey,
|
||||
ChaptersKey: chaptersKey,
|
||||
ChaptersTotal: chapterCount,
|
||||
InitiatorUserID: "",
|
||||
})
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "create import task: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, importResponse{
|
||||
TaskID: taskID,
|
||||
Slug: slug,
|
||||
TaskID: taskID,
|
||||
Slug: slug,
|
||||
Preview: &importPreview{Chapters: chapterCount},
|
||||
})
|
||||
}
|
||||
|
||||
// analyzeImportFile does a quick scan of the file to count chapters.
|
||||
// This is a placeholder - real implementation would parse PDF/EPUB properly.
|
||||
// analyzeImportFile parses the file to count chapters and extract preview lines.
|
||||
func analyzeImportFile(data []byte, fileType string) *importPreview {
|
||||
// TODO: Implement actual PDF/EPUB parsing to count chapters
|
||||
// For now, estimate based on file size
|
||||
preview := &importPreview{
|
||||
Chapters: estimateChapters(data, fileType),
|
||||
FirstLines: []string{},
|
||||
count, firstLines, err := storage.AnalyzeFile(data, fileType)
|
||||
if err != nil || count == 0 {
|
||||
// Fall back to rough size estimate so the UI still shows something
|
||||
count = estimateChapters(data, fileType)
|
||||
}
|
||||
return &importPreview{
|
||||
Chapters: count,
|
||||
FirstLines: firstLines,
|
||||
}
|
||||
return preview
|
||||
}
|
||||
|
||||
func estimateChapters(data []byte, fileType string) int {
|
||||
|
||||
@@ -3,10 +3,62 @@ package backend
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
// handleDismissNotification handles DELETE /api/notifications/{id}.
|
||||
func (s *Server) handleDismissNotification(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
jsonError(w, http.StatusBadRequest, "notification id required")
|
||||
return
|
||||
}
|
||||
if s.deps.NotificationStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
|
||||
return
|
||||
}
|
||||
if err := s.deps.NotificationStore.DeleteNotification(r.Context(), id); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "dismiss notification: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, map[string]any{"success": true})
|
||||
}
|
||||
|
||||
// handleClearAllNotifications handles DELETE /api/notifications?user_id=...
|
||||
func (s *Server) handleClearAllNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
if userID == "" {
|
||||
jsonError(w, http.StatusBadRequest, "user_id required")
|
||||
return
|
||||
}
|
||||
if s.deps.NotificationStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
|
||||
return
|
||||
}
|
||||
if err := s.deps.NotificationStore.ClearAllNotifications(r.Context(), userID); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "clear notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, map[string]any{"success": true})
|
||||
}
|
||||
|
||||
// handleMarkAllNotificationsRead handles PATCH /api/notifications?user_id=...
|
||||
func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
if userID == "" {
|
||||
jsonError(w, http.StatusBadRequest, "user_id required")
|
||||
return
|
||||
}
|
||||
if s.deps.NotificationStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
|
||||
return
|
||||
}
|
||||
if err := s.deps.NotificationStore.MarkAllNotificationsRead(r.Context(), userID); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "mark all read: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, map[string]any{"success": true})
|
||||
}
|
||||
|
||||
type notification struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
@@ -23,13 +75,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
if s.deps.NotificationStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := store.ListNotifications(r.Context(), userID, 50)
|
||||
items, err := s.deps.NotificationStore.ListNotifications(r.Context(), userID, 50)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "list notifications: "+err.Error())
|
||||
return
|
||||
@@ -40,7 +91,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
for _, item := range items {
|
||||
b, _ := json.Marshal(item)
|
||||
var n notification
|
||||
json.Unmarshal(b, &n)
|
||||
json.Unmarshal(b, &n) //nolint:errcheck
|
||||
notifications = append(notifications, n)
|
||||
}
|
||||
|
||||
@@ -54,16 +105,15 @@ func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
if s.deps.NotificationStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.MarkNotificationRead(r.Context(), id); err != nil {
|
||||
if err := s.deps.NotificationStore.MarkNotificationRead(r.Context(), id); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "mark read: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]any{"success": true})
|
||||
}
|
||||
}
|
||||
|
||||
87
backend/internal/backend/handlers_push.go
Normal file
87
backend/internal/backend/handlers_push.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
// handleGetVAPIDPublicKey handles GET /api/push-subscriptions/vapid-public-key.
|
||||
// Returns the VAPID public key so the SvelteKit frontend can subscribe browsers.
|
||||
func (s *Server) handleGetVAPIDPublicKey(w http.ResponseWriter, r *http.Request) {
|
||||
key := os.Getenv("VAPID_PUBLIC_KEY")
|
||||
if key == "" {
|
||||
jsonError(w, http.StatusServiceUnavailable, "push notifications not configured")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, map[string]string{"public_key": key})
|
||||
}
|
||||
|
||||
// handleSavePushSubscription handles POST /api/push-subscriptions.
|
||||
// Registers a new browser push subscription for the authenticated user.
|
||||
func (s *Server) handleSavePushSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
UserID string `json:"user_id"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
P256DH string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.UserID == "" || body.Endpoint == "" || body.P256DH == "" || body.Auth == "" {
|
||||
jsonError(w, http.StatusBadRequest, "user_id, endpoint, p256dh and auth are required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.SavePushSubscription(r.Context(), storage.PushSubscription{
|
||||
UserID: body.UserID,
|
||||
Endpoint: body.Endpoint,
|
||||
P256DH: body.P256DH,
|
||||
Auth: body.Auth,
|
||||
}); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "save push subscription: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]any{"success": true})
|
||||
}
|
||||
|
||||
// handleDeletePushSubscription handles DELETE /api/push-subscriptions.
|
||||
// Removes a push subscription by endpoint for the given user.
|
||||
func (s *Server) handleDeletePushSubscription(w http.ResponseWriter, r *http.Request) {
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
UserID string `json:"user_id"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.UserID == "" || body.Endpoint == "" {
|
||||
jsonError(w, http.StatusBadRequest, "user_id and endpoint are required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.DeletePushSubscription(r.Context(), body.UserID, body.Endpoint); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "delete push subscription: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]any{"success": true})
|
||||
}
|
||||
141
backend/internal/backend/handlers_split.go
Normal file
141
backend/internal/backend/handlers_split.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/libnovel/backend/internal/bookstore"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// handleAdminSplitChapters handles POST /api/admin/books/{slug}/split-chapters.
|
||||
//
|
||||
// Request body (JSON):
|
||||
//
|
||||
// { "text": "<full text with --- dividers and optional ## Title lines>" }
|
||||
//
|
||||
// The text is split on lines containing only "---". Each segment may start with
|
||||
// a "## Title" line which becomes the chapter title; remaining lines are the
|
||||
// chapter content. Sequential chapter numbers 1..N are assigned.
|
||||
//
|
||||
// All existing chapters for the book are replaced: WriteChapter is called for
|
||||
// each new chapter (upsert by number), so chapters beyond N are not deleted —
|
||||
// use the dedup endpoint afterwards if needed.
|
||||
func (s *Server) handleAdminSplitChapters(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.BookWriter == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
|
||||
return
|
||||
}
|
||||
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Text) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "text is required")
|
||||
return
|
||||
}
|
||||
|
||||
chapters := splitChapterText(req.Text)
|
||||
if len(chapters) == 0 {
|
||||
jsonError(w, http.StatusUnprocessableEntity, "no chapters produced from text")
|
||||
return
|
||||
}
|
||||
|
||||
for _, ch := range chapters {
|
||||
var mdContent string
|
||||
if ch.Title != "" && ch.Title != fmt.Sprintf("Chapter %d", ch.Number) {
|
||||
mdContent = fmt.Sprintf("# %s\n\n%s", ch.Title, ch.Content)
|
||||
} else {
|
||||
mdContent = fmt.Sprintf("# Chapter %d\n\n%s", ch.Number, ch.Content)
|
||||
}
|
||||
domainCh := domain.Chapter{
|
||||
Ref: domain.ChapterRef{Number: ch.Number, Title: ch.Title},
|
||||
Text: mdContent,
|
||||
}
|
||||
if err := s.deps.BookWriter.WriteChapter(r.Context(), slug, domainCh); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, fmt.Sprintf("write chapter %d: %s", ch.Number, err.Error()))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]any{
|
||||
"chapters": len(chapters),
|
||||
"slug": slug,
|
||||
})
|
||||
}
|
||||
|
||||
// splitChapterText splits text on "---" divider lines into bookstore.Chapter
|
||||
// slices. Each segment may optionally start with a "## Title" header line.
|
||||
func splitChapterText(text string) []bookstore.Chapter {
|
||||
lines := strings.Split(text, "\n")
|
||||
|
||||
// Collect raw segments split on "---" dividers.
|
||||
var segments [][]string
|
||||
cur := []string{}
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
segments = append(segments, cur)
|
||||
cur = []string{}
|
||||
} else {
|
||||
cur = append(cur, line)
|
||||
}
|
||||
}
|
||||
segments = append(segments, cur) // last segment
|
||||
|
||||
var chapters []bookstore.Chapter
|
||||
chNum := 0
|
||||
for _, seg := range segments {
|
||||
// Trim leading/trailing blank lines from the segment.
|
||||
start, end := 0, len(seg)
|
||||
for start < end && strings.TrimSpace(seg[start]) == "" {
|
||||
start++
|
||||
}
|
||||
for end > start && strings.TrimSpace(seg[end-1]) == "" {
|
||||
end--
|
||||
}
|
||||
seg = seg[start:end]
|
||||
if len(seg) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for a "## Title" header on the first line.
|
||||
title := ""
|
||||
contentStart := 0
|
||||
if strings.HasPrefix(strings.TrimSpace(seg[0]), "## ") {
|
||||
title = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(seg[0]), "## "))
|
||||
contentStart = 1
|
||||
// Skip blank lines after the title.
|
||||
for contentStart < len(seg) && strings.TrimSpace(seg[contentStart]) == "" {
|
||||
contentStart++
|
||||
}
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(strings.Join(seg[contentStart:], "\n"))
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
chNum++
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Chapter %d", chNum)
|
||||
}
|
||||
chapters = append(chapters, bookstore.Chapter{
|
||||
Number: chNum,
|
||||
Title: title,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
161
backend/internal/backend/handlers_submit.go
Normal file
161
backend/internal/backend/handlers_submit.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
// handleAdminPublishBook handles PATCH /api/admin/books/{slug}/publish.
|
||||
// Sets visibility=public so the book is visible to all users.
|
||||
func (s *Server) handleAdminPublishBook(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
if s.deps.BookAdminStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
|
||||
return
|
||||
}
|
||||
if err := s.deps.BookAdminStore.PublishBook(r.Context(), slug); err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "book not found")
|
||||
return
|
||||
}
|
||||
s.deps.Log.Error("publish book failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Sync the visibility change to Meilisearch immediately.
|
||||
if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok {
|
||||
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
|
||||
s.deps.Log.Warn("publish book: meili upsert failed", "slug", slug, "err", upsertErr)
|
||||
}
|
||||
}
|
||||
s.deps.Log.Info("book published", "slug", slug)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "visibility": domain.VisibilityPublic})
|
||||
}
|
||||
|
||||
// handleAdminUnpublishBook handles PATCH /api/admin/books/{slug}/unpublish.
|
||||
// Sets visibility=admin_only, hiding the book from regular users.
|
||||
func (s *Server) handleAdminUnpublishBook(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "missing slug")
|
||||
return
|
||||
}
|
||||
if s.deps.BookAdminStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
|
||||
return
|
||||
}
|
||||
if err := s.deps.BookAdminStore.UnpublishBook(r.Context(), slug); err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "book not found")
|
||||
return
|
||||
}
|
||||
s.deps.Log.Error("unpublish book failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
// Sync to Meilisearch.
|
||||
if meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug); err == nil && ok {
|
||||
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
|
||||
s.deps.Log.Warn("unpublish book: meili upsert failed", "slug", slug, "err", upsertErr)
|
||||
}
|
||||
}
|
||||
s.deps.Log.Info("book unpublished", "slug", slug)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"slug": slug, "visibility": domain.VisibilityAdminOnly})
|
||||
}
|
||||
|
||||
// handleAdminSubmitBook handles POST /api/admin/books/submit.
|
||||
// Creates a new author-submitted book with visibility=public.
|
||||
// The book starts with zero chapters; chapters are added via the import pipeline.
|
||||
func (s *Server) handleAdminSubmitBook(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Cover string `json:"cover"`
|
||||
Summary string `json:"summary"`
|
||||
Genres []string `json:"genres"`
|
||||
Status string `json:"status"`
|
||||
SubmittedBy string `json:"submitted_by"` // app_users ID of submitting author
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
req.Title = strings.TrimSpace(req.Title)
|
||||
if req.Title == "" {
|
||||
jsonError(w, http.StatusBadRequest, "title is required")
|
||||
return
|
||||
}
|
||||
if req.Status == "" {
|
||||
req.Status = "ongoing"
|
||||
}
|
||||
|
||||
slug := slugifyTitle(req.Title)
|
||||
if slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "could not derive a slug from title")
|
||||
return
|
||||
}
|
||||
|
||||
if s.deps.BookAdminStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "book admin store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
meta := domain.BookMeta{
|
||||
Slug: slug,
|
||||
Title: req.Title,
|
||||
Author: req.Author,
|
||||
Cover: req.Cover,
|
||||
Summary: req.Summary,
|
||||
Genres: req.Genres,
|
||||
Status: req.Status,
|
||||
Visibility: domain.VisibilityPublic,
|
||||
SubmittedBy: req.SubmittedBy,
|
||||
}
|
||||
if err := s.deps.BookAdminStore.CreateSubmittedBook(r.Context(), meta); err != nil {
|
||||
s.deps.Log.Error("submit book: create failed", "slug", slug, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to create book")
|
||||
return
|
||||
}
|
||||
|
||||
// Index in Meilisearch immediately so it appears in search/catalogue.
|
||||
if upsertErr := s.deps.SearchIndex.UpsertBook(r.Context(), meta); upsertErr != nil {
|
||||
s.deps.Log.Warn("submit book: meili upsert failed", "slug", slug, "err", upsertErr)
|
||||
}
|
||||
|
||||
s.deps.Log.Info("book submitted", "slug", slug, "title", req.Title, "by", req.SubmittedBy)
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"slug": slug})
|
||||
}
|
||||
|
||||
// slugifyTitle converts a book title into a URL-safe slug.
|
||||
// e.g. "The Wandering Sword" → "the-wandering-sword"
|
||||
var nonAlnum = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
func slugifyTitle(title string) string {
|
||||
// Fold to lower-case ASCII, replace non-alphanum runs with hyphens.
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(title) {
|
||||
if r <= unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
slug := nonAlnum.ReplaceAllString(b.String(), "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
if len(slug) > 80 {
|
||||
slug = slug[:80]
|
||||
slug = strings.TrimRight(slug, "-")
|
||||
}
|
||||
return slug
|
||||
}
|
||||
@@ -233,6 +233,7 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
}
|
||||
|
||||
var allResults []proposedChapterTitle
|
||||
chaptersDone := resumeFrom
|
||||
firstEvent := true
|
||||
for i, batch := range batches {
|
||||
@@ -287,6 +288,7 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
NewTitle: p.Title,
|
||||
})
|
||||
}
|
||||
allResults = append(allResults, result...)
|
||||
chaptersDone += len(batch)
|
||||
|
||||
if jobID != "" && s.deps.AIJobStore != nil {
|
||||
@@ -310,16 +312,22 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
sseWrite(evt)
|
||||
}
|
||||
|
||||
// Mark job as done in PB.
|
||||
// Mark job as done in PB, persisting results so the Review button works.
|
||||
// Use context.Background() — r.Context() may be cancelled if the SSE client
|
||||
// disconnected before processing finished, which would silently drop results.
|
||||
if jobID != "" && s.deps.AIJobStore != nil {
|
||||
status := domain.TaskStatusDone
|
||||
if jobCtx.Err() != nil {
|
||||
status = domain.TaskStatusCancelled
|
||||
}
|
||||
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
|
||||
resultsJSON, _ := json.Marshal(allResults)
|
||||
finalPayload := fmt.Sprintf(`{"pattern":%q,"slug":%q,"results":%s}`,
|
||||
req.Pattern, req.Slug, string(resultsJSON))
|
||||
_ = s.deps.AIJobStore.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||
"status": string(status),
|
||||
"items_done": chaptersDone,
|
||||
"finished": time.Now().Format(time.RFC3339),
|
||||
"payload": finalPayload,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -85,9 +85,19 @@ type Dependencies struct {
|
||||
// BookWriter writes book metadata and chapter refs to PocketBase.
|
||||
// Used by admin text-gen apply endpoints.
|
||||
BookWriter bookstore.BookWriter
|
||||
// ImportFileStore uploads raw PDF/EPUB files to MinIO for the runner to process.
|
||||
// Always wired to the concrete *storage.Store (not the Asynq wrapper).
|
||||
ImportFileStore bookstore.ImportFileStore
|
||||
// AIJobStore tracks long-running AI generation jobs in PocketBase.
|
||||
// If nil, job persistence is disabled (jobs still run but are not recorded).
|
||||
AIJobStore bookstore.AIJobStore
|
||||
// BookAdminStore provides admin-only operations: archive, unarchive, hard-delete.
|
||||
// If nil, the admin book management endpoints return 503.
|
||||
BookAdminStore bookstore.BookAdminStore
|
||||
// NotificationStore manages per-user in-app notifications.
|
||||
// Always wired directly to *storage.Store (not the Asynq wrapper) so
|
||||
// notification endpoints work regardless of whether Redis/Asynq is in use.
|
||||
NotificationStore bookstore.NotificationStore
|
||||
// Log is the structured logger.
|
||||
Log *slog.Logger
|
||||
}
|
||||
@@ -101,6 +111,9 @@ type Config struct {
|
||||
// Version and Commit are embedded in /health and /api/version responses.
|
||||
Version string
|
||||
Commit string
|
||||
// AdminToken is the bearer token required for all /api/admin/* endpoints.
|
||||
// When empty a startup warning is logged and admin routes are unprotected.
|
||||
AdminToken string
|
||||
}
|
||||
|
||||
// Server is the HTTP API server.
|
||||
@@ -127,9 +140,30 @@ func New(cfg Config, deps Dependencies) *Server {
|
||||
return &Server{cfg: cfg, deps: deps}
|
||||
}
|
||||
|
||||
// requireAdmin returns a handler that enforces Bearer token authentication.
|
||||
// When AdminToken is empty all requests are allowed through (with a warning logged
|
||||
// once at startup via ListenAndServe).
|
||||
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.AdminToken == "" {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer "+s.cfg.AdminToken {
|
||||
jsonError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServe registers all routes and starts the HTTP server.
|
||||
// It blocks until ctx is cancelled, then performs a graceful shutdown.
|
||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
if s.cfg.AdminToken == "" {
|
||||
s.deps.Log.Warn("backend: BACKEND_ADMIN_TOKEN is not set — /api/admin/* endpoints are unprotected")
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Health / version
|
||||
@@ -194,64 +228,90 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /api/translation/status/{slug}/{n}", s.handleTranslationStatus)
|
||||
mux.HandleFunc("GET /api/translation/{slug}/{n}", s.handleTranslationRead)
|
||||
|
||||
// admin is a shorthand that wraps every /api/admin/* handler with bearer-token auth.
|
||||
admin := func(pattern string, h http.HandlerFunc) {
|
||||
mux.HandleFunc(pattern, s.requireAdmin(h))
|
||||
}
|
||||
|
||||
// Admin translation endpoints
|
||||
mux.HandleFunc("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
|
||||
mux.HandleFunc("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
|
||||
admin("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
|
||||
admin("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
|
||||
|
||||
// Admin audio endpoints
|
||||
mux.HandleFunc("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
|
||||
mux.HandleFunc("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
|
||||
mux.HandleFunc("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
|
||||
admin("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
|
||||
admin("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
|
||||
admin("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
|
||||
|
||||
// Admin image generation endpoints
|
||||
mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
|
||||
mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen)
|
||||
mux.HandleFunc("POST /api/admin/image-gen/async", s.handleAdminImageGenAsync)
|
||||
mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
|
||||
mux.HandleFunc("POST /api/admin/image-gen/save-chapter-image", s.handleAdminImageGenSaveChapterImage)
|
||||
admin("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
|
||||
admin("POST /api/admin/image-gen", s.handleAdminImageGen)
|
||||
admin("POST /api/admin/image-gen/async", s.handleAdminImageGenAsync)
|
||||
admin("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
|
||||
admin("POST /api/admin/image-gen/save-chapter-image", s.handleAdminImageGenSaveChapterImage)
|
||||
|
||||
// Chapter image serving
|
||||
mux.HandleFunc("GET /api/chapter-image/{domain}/{slug}/{n}", s.handleGetChapterImage)
|
||||
mux.HandleFunc("HEAD /api/chapter-image/{domain}/{slug}/{n}", s.handleHeadChapterImage)
|
||||
|
||||
// Admin text generation endpoints (chapter names + book description)
|
||||
mux.HandleFunc("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/async", s.handleAdminTextGenChapterNamesAsync)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description/async", s.handleAdminTextGenDescriptionAsync)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
|
||||
admin("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)
|
||||
admin("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
|
||||
admin("POST /api/admin/text-gen/chapter-names/async", s.handleAdminTextGenChapterNamesAsync)
|
||||
admin("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
|
||||
admin("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
|
||||
admin("POST /api/admin/text-gen/description/async", s.handleAdminTextGenDescriptionAsync)
|
||||
admin("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
|
||||
|
||||
// Admin catalogue enrichment endpoints
|
||||
mux.HandleFunc("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore)
|
||||
mux.HandleFunc("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers)
|
||||
mux.HandleFunc("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
|
||||
mux.HandleFunc("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
|
||||
admin("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline)
|
||||
admin("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres)
|
||||
admin("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres)
|
||||
admin("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings)
|
||||
admin("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore)
|
||||
admin("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers)
|
||||
admin("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
|
||||
admin("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
|
||||
|
||||
// Admin AI job tracking endpoints
|
||||
mux.HandleFunc("GET /api/admin/ai-jobs", s.handleAdminListAIJobs)
|
||||
mux.HandleFunc("GET /api/admin/ai-jobs/{id}", s.handleAdminGetAIJob)
|
||||
mux.HandleFunc("POST /api/admin/ai-jobs/{id}/cancel", s.handleAdminCancelAIJob)
|
||||
admin("GET /api/admin/ai-jobs", s.handleAdminListAIJobs)
|
||||
admin("GET /api/admin/ai-jobs/{id}", s.handleAdminGetAIJob)
|
||||
admin("POST /api/admin/ai-jobs/{id}/cancel", s.handleAdminCancelAIJob)
|
||||
|
||||
// Auto-prompt generation from book/chapter content
|
||||
mux.HandleFunc("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)
|
||||
admin("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)
|
||||
|
||||
// Admin data repair endpoints
|
||||
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
|
||||
admin("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
|
||||
|
||||
// Admin book management (soft-delete / hard-delete / publish visibility)
|
||||
admin("PATCH /api/admin/books/{slug}/archive", s.handleAdminArchiveBook)
|
||||
admin("PATCH /api/admin/books/{slug}/unarchive", s.handleAdminUnarchiveBook)
|
||||
admin("DELETE /api/admin/books/{slug}", s.handleAdminDeleteBook)
|
||||
admin("PATCH /api/admin/books/{slug}/publish", s.handleAdminPublishBook)
|
||||
admin("PATCH /api/admin/books/{slug}/unpublish", s.handleAdminUnpublishBook)
|
||||
|
||||
// Author book submission (creates a public book with no scraped content)
|
||||
admin("POST /api/admin/books/submit", s.handleAdminSubmitBook)
|
||||
|
||||
// Admin chapter split (imported books)
|
||||
admin("POST /api/admin/books/{slug}/split-chapters", s.handleAdminSplitChapters)
|
||||
|
||||
// Import (PDF/EPUB)
|
||||
mux.HandleFunc("POST /api/admin/import", s.handleAdminImport)
|
||||
mux.HandleFunc("GET /api/admin/import", s.handleAdminImportList)
|
||||
mux.HandleFunc("GET /api/admin/import/{id}", s.handleAdminImportStatus)
|
||||
admin("POST /api/admin/import", s.handleAdminImport)
|
||||
admin("GET /api/admin/import", s.handleAdminImportList)
|
||||
admin("GET /api/admin/import/{id}", s.handleAdminImportStatus)
|
||||
|
||||
// Notifications
|
||||
mux.HandleFunc("GET /api/notifications", s.handleListNotifications)
|
||||
mux.HandleFunc("PATCH /api/notifications", s.handleMarkAllNotificationsRead)
|
||||
mux.HandleFunc("PATCH /api/notifications/{id}", s.handleMarkNotificationRead)
|
||||
mux.HandleFunc("DELETE /api/notifications", s.handleClearAllNotifications)
|
||||
mux.HandleFunc("DELETE /api/notifications/{id}", s.handleDismissNotification)
|
||||
|
||||
// Web Push subscriptions
|
||||
mux.HandleFunc("GET /api/push-subscriptions/vapid-public-key", s.handleGetVAPIDPublicKey)
|
||||
mux.HandleFunc("POST /api/push-subscriptions", s.handleSavePushSubscription)
|
||||
mux.HandleFunc("DELETE /api/push-subscriptions", s.handleDeletePushSubscription)
|
||||
|
||||
// Voices list
|
||||
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||
|
||||
@@ -215,3 +215,55 @@ type BookImporter interface {
|
||||
// Returns the extracted chapters or an error.
|
||||
Import(ctx context.Context, objectKey, fileType string) ([]Chapter, error)
|
||||
}
|
||||
|
||||
// BookAdminStore covers admin-only operations for managing books in the catalogue.
|
||||
// All methods require admin authorisation at the HTTP handler level.
|
||||
type BookAdminStore interface {
|
||||
// ArchiveBook sets archived=true on a book record, hiding it from all
|
||||
// public search and catalogue responses. Returns ErrNotFound when the
|
||||
// slug does not exist.
|
||||
ArchiveBook(ctx context.Context, slug string) error
|
||||
|
||||
// UnarchiveBook clears archived on a book record, making it publicly
|
||||
// visible again. Returns ErrNotFound when the slug does not exist.
|
||||
UnarchiveBook(ctx context.Context, slug string) error
|
||||
|
||||
// DeleteBook permanently removes all data for a book:
|
||||
// - PocketBase books record
|
||||
// - All PocketBase chapters_idx records
|
||||
// - All MinIO chapter markdown objects ({slug}/chapter-*.md)
|
||||
// - MinIO cover image (covers/{slug}.jpg)
|
||||
// The caller is responsible for also deleting the Meilisearch document.
|
||||
DeleteBook(ctx context.Context, slug string) error
|
||||
|
||||
// PublishBook sets visibility=public, making the book visible to all users.
|
||||
PublishBook(ctx context.Context, slug string) error
|
||||
|
||||
// UnpublishBook sets visibility=admin_only, hiding the book from regular users.
|
||||
UnpublishBook(ctx context.Context, slug string) error
|
||||
|
||||
// CreateSubmittedBook creates a new author-submitted book with visibility=public.
|
||||
CreateSubmittedBook(ctx context.Context, meta domain.BookMeta) error
|
||||
}
|
||||
|
||||
// ImportFileStore uploads raw import files to object storage.
|
||||
// Kept separate from BookImporter so the HTTP handler can upload the file
|
||||
// without a concrete type assertion, regardless of which Producer is wired.
|
||||
type ImportFileStore interface {
|
||||
PutImportFile(ctx context.Context, objectKey string, data []byte) error
|
||||
// PutImportChapters stores the pre-parsed chapters JSON under the given key.
|
||||
PutImportChapters(ctx context.Context, key string, data []byte) error
|
||||
// GetImportChapters retrieves the pre-parsed chapters JSON.
|
||||
GetImportChapters(ctx context.Context, key string) ([]byte, error)
|
||||
}
|
||||
|
||||
// NotificationStore manages per-user in-app notifications.
|
||||
// Always wired directly to the concrete *storage.Store so it works
|
||||
// regardless of whether the Asynq task-queue wrapper is in use.
|
||||
type NotificationStore interface {
|
||||
ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error)
|
||||
MarkNotificationRead(ctx context.Context, id string) error
|
||||
MarkAllNotificationsRead(ctx context.Context, userID string) error
|
||||
DeleteNotification(ctx context.Context, id string) error
|
||||
ClearAllNotifications(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
@@ -92,6 +92,10 @@ type LibreTranslate struct {
|
||||
type HTTP struct {
|
||||
// Addr is the listen address, e.g. ":8080"
|
||||
Addr string
|
||||
// AdminToken is the bearer token required for all /api/admin/* endpoints.
|
||||
// Set via BACKEND_ADMIN_TOKEN. When empty, admin endpoints are unprotected —
|
||||
// only acceptable when the backend is unreachable from the public internet.
|
||||
AdminToken string
|
||||
}
|
||||
|
||||
// Meilisearch holds connection settings for the Meilisearch full-text search service.
|
||||
@@ -123,6 +127,19 @@ type Redis struct {
|
||||
Password string
|
||||
}
|
||||
|
||||
// VAPID holds Web Push VAPID key pair for browser push notifications.
|
||||
// Generate a pair once with: go run ./cmd/genkeys (or use the web-push CLI).
|
||||
// The public key is exposed via GET /api/push-subscriptions/vapid-public-key
|
||||
// and embedded in the SvelteKit app via PUBLIC_VAPID_PUBLIC_KEY.
|
||||
type VAPID struct {
|
||||
// PublicKey is the base64url-encoded VAPID public key (65 bytes, uncompressed EC P-256).
|
||||
PublicKey string
|
||||
// PrivateKey is the base64url-encoded VAPID private key (32 bytes).
|
||||
PrivateKey string
|
||||
// Subject is the mailto: or https: URL used as the VAPID subscriber contact.
|
||||
Subject string
|
||||
}
|
||||
|
||||
// Runner holds settings specific to the runner/worker binary.
|
||||
type Runner struct {
|
||||
// PollInterval is how often the runner checks PocketBase for pending tasks.
|
||||
@@ -172,6 +189,7 @@ type Config struct {
|
||||
Meilisearch Meilisearch
|
||||
Valkey Valkey
|
||||
Redis Redis
|
||||
VAPID VAPID
|
||||
// LogLevel is one of "debug", "info", "warn", "error".
|
||||
LogLevel string
|
||||
}
|
||||
@@ -228,7 +246,8 @@ func Load() Config {
|
||||
},
|
||||
|
||||
HTTP: HTTP{
|
||||
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
|
||||
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
|
||||
AdminToken: envOr("BACKEND_ADMIN_TOKEN", ""),
|
||||
},
|
||||
|
||||
Runner: Runner{
|
||||
@@ -258,6 +277,12 @@ func Load() Config {
|
||||
Addr: envOr("REDIS_ADDR", ""),
|
||||
Password: envOr("REDIS_PASSWORD", ""),
|
||||
},
|
||||
|
||||
VAPID: VAPID{
|
||||
PublicKey: envOr("VAPID_PUBLIC_KEY", ""),
|
||||
PrivateKey: envOr("VAPID_PRIVATE_KEY", ""),
|
||||
Subject: envOr("VAPID_SUBJECT", "mailto:admin@libnovel.cc"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ import "time"
|
||||
|
||||
// ── Book types ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Visibility values for BookMeta.Visibility.
|
||||
const (
|
||||
VisibilityPublic = "public" // visible to all users
|
||||
VisibilityAdminOnly = "admin_only" // visible only to admin users (e.g. scraped content)
|
||||
)
|
||||
|
||||
// BookMeta carries all bibliographic information about a novel.
|
||||
type BookMeta struct {
|
||||
Slug string `json:"slug"`
|
||||
@@ -24,6 +30,15 @@ type BookMeta struct {
|
||||
// updated in PocketBase. Populated on read; not sent on write (PocketBase
|
||||
// manages its own updated field).
|
||||
MetaUpdated int64 `json:"meta_updated,omitempty"`
|
||||
// Archived is true when the book has been soft-deleted by an admin.
|
||||
// Archived books are excluded from all public search and catalogue responses.
|
||||
Archived bool `json:"archived,omitempty"`
|
||||
// Visibility controls who can see this book.
|
||||
// "public" = all users; "admin_only" = admin only (default for scraped content).
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
// SubmittedBy is the app_users ID of the author who submitted this book,
|
||||
// or empty for scraped books.
|
||||
SubmittedBy string `json:"submitted_by,omitempty"`
|
||||
}
|
||||
|
||||
// CatalogueEntry is a lightweight book reference returned by catalogue pages.
|
||||
@@ -123,6 +138,8 @@ type ScrapeTask struct {
|
||||
|
||||
// ScrapeResult is the outcome reported by the runner after finishing a ScrapeTask.
|
||||
type ScrapeResult struct {
|
||||
// Slug is the book slug that was scraped. Empty for catalogue tasks.
|
||||
Slug string `json:"slug,omitempty"`
|
||||
BooksFound int `json:"books_found"`
|
||||
ChaptersScraped int `json:"chapters_scraped"`
|
||||
ChaptersSkipped int `json:"chapters_skipped"`
|
||||
@@ -177,6 +194,13 @@ type ImportTask struct {
|
||||
Title string `json:"title"`
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"` // "pdf" or "epub"
|
||||
ObjectKey string `json:"object_key,omitempty"` // MinIO path to uploaded file
|
||||
ChaptersKey string `json:"chapters_key,omitempty"` // MinIO path to pre-parsed chapters JSON
|
||||
Author string `json:"author,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
BookStatus string `json:"book_status,omitempty"` // "ongoing" | "completed" | "hiatus"
|
||||
WorkerID string `json:"worker_id,omitempty"`
|
||||
InitiatorUserID string `json:"initiator_user_id,omitempty"` // PocketBase user ID who submitted the import
|
||||
Status TaskStatus `json:"status"`
|
||||
|
||||
@@ -32,11 +32,15 @@ type Client interface {
|
||||
// BookExists reports whether a book with the given slug is already in the
|
||||
// index. Used by the catalogue refresh to skip re-indexing known books.
|
||||
BookExists(ctx context.Context, slug string) bool
|
||||
// DeleteBook removes a book document from the search index by slug.
|
||||
DeleteBook(ctx context.Context, slug string) error
|
||||
// Search returns up to limit books matching query.
|
||||
// Archived books are always excluded.
|
||||
Search(ctx context.Context, query string, limit int) ([]domain.BookMeta, error)
|
||||
// Catalogue queries books with optional filters, sort, and pagination.
|
||||
// Returns books, the total hit count for pagination, and a FacetResult
|
||||
// with available genre and status values from the index.
|
||||
// Archived books are always excluded.
|
||||
Catalogue(ctx context.Context, q CatalogueQuery) ([]domain.BookMeta, int64, FacetResult, error)
|
||||
}
|
||||
|
||||
@@ -48,6 +52,9 @@ type CatalogueQuery struct {
|
||||
Sort string // sort field: "popular", "new", "update", "top-rated", "rank", ""
|
||||
Page int // 1-indexed
|
||||
Limit int // items per page, default 20
|
||||
// AdminAll disables the visibility filter so admin users see all non-archived
|
||||
// books including those marked admin_only.
|
||||
AdminAll bool
|
||||
}
|
||||
|
||||
// FacetResult holds the available filter values discovered from the index.
|
||||
@@ -99,7 +106,7 @@ func Configure(host, apiKey string) error {
|
||||
return fmt.Errorf("meili: update searchable attributes: %w", err)
|
||||
}
|
||||
|
||||
filterable := []interface{}{"status", "genres"}
|
||||
filterable := []interface{}{"status", "genres", "archived", "visibility"}
|
||||
if _, err := idx.UpdateFilterableAttributes(&filterable); err != nil {
|
||||
return fmt.Errorf("meili: update filterable attributes: %w", err)
|
||||
}
|
||||
@@ -128,6 +135,12 @@ type bookDoc struct {
|
||||
// MetaUpdated is the Unix timestamp (seconds) of the last PocketBase update.
|
||||
// Used for sort=update ("recently updated" ordering).
|
||||
MetaUpdated int64 `json:"meta_updated"`
|
||||
// Archived is true when the book has been soft-deleted by an admin.
|
||||
// Used as a filter to exclude archived books from all search results.
|
||||
Archived bool `json:"archived"`
|
||||
// Visibility is "public" or "admin_only". Only public books are shown to
|
||||
// non-admin users. Empty string is treated as admin_only for safety.
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
func toDoc(b domain.BookMeta) bookDoc {
|
||||
@@ -144,6 +157,8 @@ func toDoc(b domain.BookMeta) bookDoc {
|
||||
Rank: b.Ranking,
|
||||
Rating: b.Rating,
|
||||
MetaUpdated: b.MetaUpdated,
|
||||
Archived: b.Archived,
|
||||
Visibility: b.Visibility,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +176,8 @@ func fromDoc(d bookDoc) domain.BookMeta {
|
||||
Ranking: d.Rank,
|
||||
Rating: d.Rating,
|
||||
MetaUpdated: d.MetaUpdated,
|
||||
Archived: d.Archived,
|
||||
Visibility: d.Visibility,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,13 +201,24 @@ func (c *MeiliClient) BookExists(_ context.Context, slug string) bool {
|
||||
return err == nil && doc.Slug != ""
|
||||
}
|
||||
|
||||
// DeleteBook removes a book document from the index by slug.
|
||||
// The operation is fire-and-forget (Meilisearch processes tasks asynchronously).
|
||||
func (c *MeiliClient) DeleteBook(_ context.Context, slug string) error {
|
||||
if _, err := c.idx.DeleteDocument(slug, nil); err != nil {
|
||||
return fmt.Errorf("meili: delete book %q: %w", slug, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Search returns books matching query, up to limit results.
|
||||
// Archived books are always excluded.
|
||||
func (c *MeiliClient) Search(_ context.Context, query string, limit int) ([]domain.BookMeta, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
res, err := c.idx.Search(query, &meilisearch.SearchRequest{
|
||||
Limit: int64(limit),
|
||||
Limit: int64(limit),
|
||||
Filter: `archived = false AND visibility = "public"`,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("meili: search %q: %w", query, err)
|
||||
@@ -231,17 +259,18 @@ func (c *MeiliClient) Catalogue(_ context.Context, q CatalogueQuery) ([]domain.B
|
||||
Facets: []string{"genres", "status"},
|
||||
}
|
||||
|
||||
// Build filter
|
||||
var filters []string
|
||||
// Build filter — always exclude archived books; restrict to public unless admin.
|
||||
filters := []string{"archived = false"}
|
||||
if !q.AdminAll {
|
||||
filters = append(filters, `visibility = "public"`)
|
||||
}
|
||||
if q.Genre != "" && q.Genre != "all" {
|
||||
filters = append(filters, fmt.Sprintf("genres = %q", q.Genre))
|
||||
}
|
||||
if q.Status != "" && q.Status != "all" {
|
||||
filters = append(filters, fmt.Sprintf("status = %q", q.Status))
|
||||
}
|
||||
if len(filters) > 0 {
|
||||
req.Filter = strings.Join(filters, " AND ")
|
||||
}
|
||||
req.Filter = strings.Join(filters, " AND ")
|
||||
|
||||
// Map UI sort tokens to Meilisearch sort expressions.
|
||||
switch q.Sort {
|
||||
@@ -318,7 +347,8 @@ func sortStrings(s []string) {
|
||||
type NoopClient struct{}
|
||||
|
||||
func (NoopClient) UpsertBook(_ context.Context, _ domain.BookMeta) error { return nil }
|
||||
func (NoopClient) BookExists(_ context.Context, _ string) bool { return false }
|
||||
func (NoopClient) BookExists(_ context.Context, _ string) bool { return false }
|
||||
func (NoopClient) DeleteBook(_ context.Context, _ string) error { return nil }
|
||||
func (NoopClient) Search(_ context.Context, _ string, _ int) ([]domain.BookMeta, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -241,7 +241,7 @@ func (s *Scraper) ScrapeChapterList(ctx context.Context, bookURL string, upTo in
|
||||
}
|
||||
|
||||
pageURL := fmt.Sprintf("%s?page=%d", baseChapterURL, page)
|
||||
s.log.Info("scraping chapter list", "page", page, "url", pageURL)
|
||||
s.log.Debug("scraping chapter list", "page", page, "url", pageURL)
|
||||
|
||||
raw, err := retryGet(ctx, s.log, s.client, pageURL, 9, 6*time.Second)
|
||||
if err != nil {
|
||||
|
||||
@@ -68,7 +68,7 @@ func New(cfg Config, novel scraper.NovelScraper, store bookstore.BookWriter, log
|
||||
// Returns a ScrapeResult with counters. The result's ErrorMessage is non-empty
|
||||
// if the run failed at the metadata or chapter-list level.
|
||||
func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) domain.ScrapeResult {
|
||||
o.log.Info("orchestrator: RunBook starting",
|
||||
o.log.Debug("orchestrator: RunBook starting",
|
||||
"task_id", task.ID,
|
||||
"kind", task.Kind,
|
||||
"url", task.TargetURL,
|
||||
@@ -90,6 +90,7 @@ func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) doma
|
||||
result.Errors++
|
||||
return result
|
||||
}
|
||||
result.Slug = meta.Slug
|
||||
|
||||
if err := o.store.WriteMetadata(ctx, meta); err != nil {
|
||||
o.log.Error("metadata write failed", "slug", meta.Slug, "err", err)
|
||||
@@ -97,13 +98,14 @@ func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) doma
|
||||
result.Errors++
|
||||
} else {
|
||||
result.BooksFound = 1
|
||||
result.Slug = meta.Slug
|
||||
// Fire optional post-metadata hook (e.g. Meilisearch indexing).
|
||||
if o.postMetadata != nil {
|
||||
o.postMetadata(ctx, meta)
|
||||
}
|
||||
}
|
||||
|
||||
o.log.Info("metadata saved", "slug", meta.Slug, "title", meta.Title)
|
||||
o.log.Debug("metadata saved", "slug", meta.Slug, "title", meta.Title)
|
||||
|
||||
// ── Step 2: Chapter list ──────────────────────────────────────────────────
|
||||
refs, err := o.novel.ScrapeChapterList(ctx, task.TargetURL, task.ToChapter)
|
||||
@@ -114,7 +116,7 @@ func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) doma
|
||||
return result
|
||||
}
|
||||
|
||||
o.log.Info("chapter list fetched", "slug", meta.Slug, "chapters", len(refs))
|
||||
o.log.Debug("chapter list fetched", "slug", meta.Slug, "chapters", len(refs))
|
||||
|
||||
// Persist chapter refs (without text) so the index exists early.
|
||||
if wErr := o.store.WriteChapterRefs(ctx, meta.Slug, refs); wErr != nil {
|
||||
|
||||
@@ -199,10 +199,11 @@ func (r *Runner) handleImportTask(ctx context.Context, t *asynq.Task) error {
|
||||
return fmt.Errorf("unmarshal import payload: %w", err)
|
||||
}
|
||||
task := domain.ImportTask{
|
||||
ID: p.PBTaskID,
|
||||
Slug: p.Slug,
|
||||
Title: p.Title,
|
||||
FileType: p.FileType,
|
||||
ID: p.PBTaskID,
|
||||
Slug: p.Slug,
|
||||
Title: p.Title,
|
||||
FileType: p.FileType,
|
||||
ChaptersKey: p.ChaptersKey,
|
||||
}
|
||||
r.tasksRunning.Add(1)
|
||||
defer r.tasksRunning.Add(-1)
|
||||
|
||||
@@ -19,3 +19,53 @@ func stripMarkdown(src string) string {
|
||||
src = regexp.MustCompile(`\n{3,}`).ReplaceAllString(src, "\n\n")
|
||||
return strings.TrimSpace(src)
|
||||
}
|
||||
|
||||
// chunkText splits text into chunks of at most maxChars characters, breaking
|
||||
// at sentence boundaries (". ", "! ", "? ", "\n") so that the TTS service
|
||||
// receives natural prose fragments rather than mid-sentence cuts.
|
||||
//
|
||||
// If a single sentence exceeds maxChars it is included as its own chunk —
|
||||
// never silently truncated.
|
||||
func chunkText(text string, maxChars int) []string {
|
||||
if len(text) <= maxChars {
|
||||
return []string{text}
|
||||
}
|
||||
|
||||
// Sentence-boundary delimiters — we split AFTER these sequences.
|
||||
// Order matters: longer sequences first.
|
||||
delimiters := []string{".\n", "!\n", "?\n", ". ", "! ", "? ", "\n\n", "\n"}
|
||||
|
||||
var chunks []string
|
||||
remaining := text
|
||||
|
||||
for len(remaining) > 0 {
|
||||
if len(remaining) <= maxChars {
|
||||
chunks = append(chunks, strings.TrimSpace(remaining))
|
||||
break
|
||||
}
|
||||
|
||||
// Find the last sentence boundary within the maxChars window.
|
||||
window := remaining[:maxChars]
|
||||
cutAt := -1
|
||||
for _, delim := range delimiters {
|
||||
idx := strings.LastIndex(window, delim)
|
||||
if idx > 0 && idx+len(delim) > cutAt {
|
||||
cutAt = idx + len(delim)
|
||||
}
|
||||
}
|
||||
|
||||
if cutAt <= 0 {
|
||||
// No boundary found — hard-break at maxChars to avoid infinite loop.
|
||||
cutAt = maxChars
|
||||
}
|
||||
|
||||
chunk := strings.TrimSpace(remaining[:cutAt])
|
||||
if chunk != "" {
|
||||
chunks = append(chunks, chunk)
|
||||
}
|
||||
remaining = strings.TrimSpace(remaining[cutAt:])
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -35,7 +36,9 @@ import (
|
||||
"github.com/libnovel/backend/internal/orchestrator"
|
||||
"github.com/libnovel/backend/internal/pockettts"
|
||||
"github.com/libnovel/backend/internal/scraper"
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
"github.com/libnovel/backend/internal/taskqueue"
|
||||
"github.com/libnovel/backend/internal/webpush"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
@@ -49,6 +52,11 @@ type ChapterIngester interface {
|
||||
IngestChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error
|
||||
}
|
||||
|
||||
// ImportChapterStore retrieves pre-parsed chapter JSON blobs from object storage.
|
||||
type ImportChapterStore interface {
|
||||
GetImportChapters(ctx context.Context, key string) ([]byte, error)
|
||||
}
|
||||
|
||||
// Config tunes the runner behaviour.
|
||||
type Config struct {
|
||||
// WorkerID uniquely identifies this runner instance in PocketBase records.
|
||||
@@ -114,11 +122,22 @@ type Dependencies struct {
|
||||
// CoverStore stores book cover images in MinIO.
|
||||
CoverStore bookstore.CoverStore
|
||||
// BookImport handles PDF/EPUB file parsing and chapter extraction.
|
||||
// Kept for backward compatibility when ChaptersKey is not set.
|
||||
BookImport bookstore.BookImporter
|
||||
// ImportChapterStore retrieves pre-parsed chapter JSON blobs from MinIO.
|
||||
// When set and the task has a ChaptersKey, the runner reads from here
|
||||
// instead of calling BookImport.Import() (the new preferred path).
|
||||
ImportChapterStore ImportChapterStore
|
||||
// ChapterIngester persists extracted chapters into MinIO/PocketBase.
|
||||
ChapterIngester ChapterIngester
|
||||
// Notifier creates notifications for users.
|
||||
Notifier Notifier
|
||||
// WebPush sends browser push notifications to subscribed users.
|
||||
// If nil, push notifications are disabled.
|
||||
WebPush *webpush.Sender
|
||||
// Store is the underlying *storage.Store; used for push subscription lookups.
|
||||
// Only needed when WebPush is non-nil.
|
||||
Store *storage.Store
|
||||
// SearchIndex indexes books in Meilisearch after scraping.
|
||||
// If nil a no-op is used.
|
||||
SearchIndex meili.Client
|
||||
@@ -432,9 +451,7 @@ importLoop:
|
||||
defer wg.Done()
|
||||
defer func() { <-importSem }()
|
||||
defer r.tasksRunning.Add(-1)
|
||||
// Import tasks need object key - we'll need to fetch it from the task record
|
||||
// For now, assume it's stored in a field or we need to add it
|
||||
r.runImportTask(ctx, t, "")
|
||||
r.runImportTask(ctx, t, t.ObjectKey)
|
||||
}(task)
|
||||
}
|
||||
}
|
||||
@@ -496,16 +513,56 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
|
||||
log.Warn("runner: unknown task kind")
|
||||
}
|
||||
|
||||
if err := r.deps.Consumer.FinishScrapeTask(ctx, task.ID, result); err != nil {
|
||||
// Use a fresh context for the final write so a cancelled task context doesn't
|
||||
// prevent the result counters from being persisted to PocketBase.
|
||||
finishCtx, finishCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer finishCancel()
|
||||
if err := r.deps.Consumer.FinishScrapeTask(finishCtx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishScrapeTask failed", "err", err)
|
||||
}
|
||||
|
||||
if result.ErrorMessage != "" {
|
||||
r.tasksFailed.Add(1)
|
||||
span.SetStatus(codes.Error, result.ErrorMessage)
|
||||
if r.deps.Notifier != nil {
|
||||
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||
"Scrape Failed",
|
||||
fmt.Sprintf("Scrape task (%s) failed: %s", task.Kind, result.ErrorMessage),
|
||||
"/admin/tasks")
|
||||
}
|
||||
} else {
|
||||
r.tasksCompleted.Add(1)
|
||||
span.SetStatus(codes.Ok, "")
|
||||
if r.deps.Notifier != nil {
|
||||
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||
"Scrape Complete",
|
||||
fmt.Sprintf("Scraped %d chapters, skipped %d (%s)", result.ChaptersScraped, result.ChaptersSkipped, task.Kind),
|
||||
"/admin/tasks")
|
||||
}
|
||||
// Fan-out in-app new-chapter notification to all users who have this book
|
||||
// in their library. Runs in background so it doesn't block the task loop.
|
||||
if r.deps.Store != nil && result.ChaptersScraped > 0 &&
|
||||
result.Slug != "" && task.Kind != "catalogue" {
|
||||
go func() {
|
||||
notifyCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
title := result.Slug
|
||||
_ = r.deps.Store.NotifyUsersWithBook(notifyCtx, result.Slug,
|
||||
"New chapters available",
|
||||
fmt.Sprintf("%d new chapter(s) added to %s", result.ChaptersScraped, title),
|
||||
"/books/"+result.Slug)
|
||||
}()
|
||||
}
|
||||
// Send Web Push notifications to subscribed browsers.
|
||||
if r.deps.WebPush != nil && r.deps.Store != nil &&
|
||||
result.ChaptersScraped > 0 && result.Slug != "" && task.Kind != "catalogue" {
|
||||
go r.deps.WebPush.SendToBook(context.Background(), r.deps.Store, result.Slug, webpush.Payload{
|
||||
Title: "New chapter available",
|
||||
Body: fmt.Sprintf("%d new chapter(s) added", result.ChaptersScraped),
|
||||
URL: "/books/" + result.Slug,
|
||||
Icon: "/icon-192.png",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("runner: scrape task finished",
|
||||
@@ -530,7 +587,7 @@ func (r *Runner) runCatalogueTask(ctx context.Context, task domain.ScrapeTask, o
|
||||
TargetURL: entry.URL,
|
||||
}
|
||||
bookResult := o.RunBook(ctx, bookTask)
|
||||
result.BooksFound += bookResult.BooksFound + 1
|
||||
result.BooksFound += bookResult.BooksFound
|
||||
result.ChaptersScraped += bookResult.ChaptersScraped
|
||||
result.ChaptersSkipped += bookResult.ChaptersSkipped
|
||||
result.Errors += bookResult.Errors
|
||||
@@ -585,6 +642,12 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
||||
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishAudioTask failed", "err", err)
|
||||
}
|
||||
if r.deps.Notifier != nil {
|
||||
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||
"Audio Failed",
|
||||
fmt.Sprintf("Ch.%d of %s (%s): %s", task.Chapter, task.Slug, task.Voice, msg),
|
||||
fmt.Sprintf("/books/%s", task.Slug))
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := r.deps.BookReader.ReadChapter(ctx, task.Slug, task.Chapter)
|
||||
@@ -629,7 +692,7 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
||||
return
|
||||
}
|
||||
var genErr error
|
||||
audioData, genErr = r.deps.Kokoro.GenerateAudio(ctx, text, task.Voice)
|
||||
audioData, genErr = kokoroGenerateChunked(ctx, r.deps.Kokoro, text, task.Voice, log)
|
||||
if genErr != nil {
|
||||
fail(fmt.Sprintf("kokoro generate: %v", genErr))
|
||||
return
|
||||
@@ -649,10 +712,45 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
||||
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishAudioTask failed", "err", err)
|
||||
}
|
||||
if r.deps.Notifier != nil {
|
||||
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||
"Audio Ready",
|
||||
fmt.Sprintf("Ch.%d of %s (%s) is ready", task.Chapter, task.Slug, task.Voice),
|
||||
fmt.Sprintf("/books/%s", task.Slug))
|
||||
}
|
||||
log.Info("runner: audio task finished", "key", key)
|
||||
}
|
||||
|
||||
// kokoroGenerateChunked splits text into ~1 000-character sentence-boundary
|
||||
// chunks, calls Kokoro.GenerateAudio for each, and concatenates the raw MP3
|
||||
// bytes. This avoids EOF / timeout failures that occur when the Kokoro
|
||||
// FastAPI server receives very large inputs (e.g. a full imported PDF chapter).
|
||||
//
|
||||
// Concatenating raw MP3 frames is valid — MP3 is a frame-based format and
|
||||
// standard players handle multi-segment files correctly.
|
||||
func kokoroGenerateChunked(ctx context.Context, k kokoro.Client, text, voice string, log *slog.Logger) ([]byte, error) {
|
||||
const chunkSize = 1000
|
||||
|
||||
chunks := chunkText(text, chunkSize)
|
||||
log.Info("runner: kokoro chunked generation", "chunks", len(chunks), "total_chars", len(text))
|
||||
|
||||
var combined []byte
|
||||
for i, chunk := range chunks {
|
||||
data, err := k.GenerateAudio(ctx, chunk, voice)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("chunk %d/%d: %w", i+1, len(chunks), err)
|
||||
}
|
||||
combined = append(combined, data...)
|
||||
log.Info("runner: kokoro chunk done", "chunk", i+1, "of", len(chunks), "bytes", len(data))
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
// runImportTask executes one PDF/EPUB import task.
|
||||
// Preferred path: when task.ChaptersKey is set, it reads pre-parsed chapters
|
||||
// JSON from MinIO (written by the backend at upload time) and ingests them.
|
||||
// Fallback path: when ChaptersKey is empty, calls BookImport.Import() to
|
||||
// parse the raw file on the runner (legacy behaviour, not used for new tasks).
|
||||
func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, objectKey string) {
|
||||
ctx, span := otel.Tracer("runner").Start(ctx, "runner.import_task")
|
||||
defer span.End()
|
||||
@@ -660,10 +758,11 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
|
||||
attribute.String("task.id", task.ID),
|
||||
attribute.String("book.slug", task.Slug),
|
||||
attribute.String("file.type", task.FileType),
|
||||
attribute.String("chapters_key", task.ChaptersKey),
|
||||
)
|
||||
|
||||
log := r.deps.Log.With("task_id", task.ID, "slug", task.Slug, "file_type", task.FileType)
|
||||
log.Info("runner: import task starting")
|
||||
log.Info("runner: import task starting", "chapters_key", task.ChaptersKey)
|
||||
|
||||
hbCtx, hbCancel := context.WithCancel(ctx)
|
||||
defer hbCancel()
|
||||
@@ -692,15 +791,33 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
|
||||
}
|
||||
}
|
||||
|
||||
if r.deps.BookImport == nil {
|
||||
fail("book import not configured (BookImport dependency missing)")
|
||||
return
|
||||
}
|
||||
var chapters []bookstore.Chapter
|
||||
|
||||
chapters, err := r.deps.BookImport.Import(ctx, objectKey, task.FileType)
|
||||
if err != nil {
|
||||
fail(fmt.Sprintf("import file: %v", err))
|
||||
return
|
||||
if task.ChaptersKey != "" && r.deps.ImportChapterStore != nil {
|
||||
// New path: read pre-parsed chapters JSON uploaded by the backend.
|
||||
raw, err := r.deps.ImportChapterStore.GetImportChapters(ctx, task.ChaptersKey)
|
||||
if err != nil {
|
||||
fail(fmt.Sprintf("get chapters JSON: %v", err))
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(raw, &chapters); err != nil {
|
||||
fail(fmt.Sprintf("unmarshal chapters JSON: %v", err))
|
||||
return
|
||||
}
|
||||
log.Info("runner: loaded pre-parsed chapters", "count", len(chapters))
|
||||
} else {
|
||||
// Legacy path: parse the raw file on the runner.
|
||||
if r.deps.BookImport == nil {
|
||||
fail("book import not configured (BookImport dependency missing)")
|
||||
return
|
||||
}
|
||||
var err error
|
||||
chapters, err = r.deps.BookImport.Import(ctx, objectKey, task.FileType)
|
||||
if err != nil {
|
||||
fail(fmt.Sprintf("import file: %v", err))
|
||||
return
|
||||
}
|
||||
log.Info("runner: parsed chapters from file (legacy path)", "count", len(chapters))
|
||||
}
|
||||
|
||||
if len(chapters) == 0 {
|
||||
@@ -708,27 +825,41 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
|
||||
return
|
||||
}
|
||||
|
||||
// Store chapters via BookWriter
|
||||
// Note: BookWriter.WriteChapters expects domain.Chapter, need conversion
|
||||
var domainChapters []bookstore.Chapter
|
||||
for _, ch := range chapters {
|
||||
domainChapters = append(domainChapters, bookstore.Chapter{
|
||||
Number: ch.Number,
|
||||
Title: ch.Title,
|
||||
Content: ch.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// Store chapters via ChapterIngester
|
||||
// Persist chapters via ChapterIngester.
|
||||
if r.deps.ChapterIngester == nil {
|
||||
fail("chapter ingester not configured")
|
||||
return
|
||||
}
|
||||
if err := r.deps.ChapterIngester.IngestChapters(ctx, task.Slug, domainChapters); err != nil {
|
||||
if err := r.deps.ChapterIngester.IngestChapters(ctx, task.Slug, chapters); err != nil {
|
||||
fail(fmt.Sprintf("store chapters: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Write book metadata so the book appears in PocketBase catalogue.
|
||||
if r.deps.BookWriter != nil {
|
||||
meta := domain.BookMeta{
|
||||
Slug: task.Slug,
|
||||
Title: task.Title,
|
||||
Author: task.Author,
|
||||
Cover: task.CoverURL,
|
||||
Status: task.BookStatus,
|
||||
Genres: task.Genres,
|
||||
Summary: task.Summary,
|
||||
TotalChapters: len(chapters),
|
||||
}
|
||||
if meta.Status == "" {
|
||||
meta.Status = "completed"
|
||||
}
|
||||
if err := r.deps.BookWriter.WriteMetadata(ctx, meta); err != nil {
|
||||
log.Warn("runner: import task WriteMetadata failed (non-fatal)", "err", err)
|
||||
} else {
|
||||
// Index in Meilisearch so the book is searchable.
|
||||
if err := r.deps.SearchIndex.UpsertBook(ctx, meta); err != nil {
|
||||
log.Warn("runner: import task meilisearch upsert failed (non-fatal)", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.tasksCompleted.Add(1)
|
||||
span.SetStatus(codes.Ok, "")
|
||||
result := domain.ImportResult{
|
||||
@@ -739,7 +870,7 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
|
||||
log.Error("runner: FinishImportTask failed", "err", err)
|
||||
}
|
||||
|
||||
// Create notification for the user who initiated the import
|
||||
// Notify the user who initiated the import.
|
||||
if r.deps.Notifier != nil {
|
||||
msg := fmt.Sprintf("Import completed: %d chapters from %s", len(chapters), task.Title)
|
||||
targetUser := task.InitiatorUserID
|
||||
|
||||
@@ -53,6 +53,12 @@ func (r *Runner) runTranslationTask(ctx context.Context, task domain.Translation
|
||||
if err := r.deps.Consumer.FinishTranslationTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishTranslationTask failed", "err", err)
|
||||
}
|
||||
if r.deps.Notifier != nil {
|
||||
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||
"Translation Failed",
|
||||
fmt.Sprintf("Ch.%d of %s (%s): %s", task.Chapter, task.Slug, task.Lang, msg),
|
||||
fmt.Sprintf("/books/%s", task.Slug))
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: LibreTranslate must be configured.
|
||||
@@ -93,5 +99,11 @@ func (r *Runner) runTranslationTask(ctx context.Context, task domain.Translation
|
||||
if err := r.deps.Consumer.FinishTranslationTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishTranslationTask failed", "err", err)
|
||||
}
|
||||
if r.deps.Notifier != nil {
|
||||
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||
"Translation Ready",
|
||||
fmt.Sprintf("Ch.%d of %s translated to %s", task.Chapter, task.Slug, task.Lang),
|
||||
fmt.Sprintf("/books/%s", task.Slug))
|
||||
}
|
||||
log.Info("runner: translation task finished", "key", key)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/libnovel/backend/internal/bookstore"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
minio "github.com/minio/minio-go/v7"
|
||||
)
|
||||
|
||||
var (
|
||||
chapterPattern = regexp.MustCompile(`(?i)chapter\s+(\d+)|The\s+Eminence\s+in\s+Shadow\s+(\d+)\s*-\s*(\d+)`)
|
||||
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
type importer struct {
|
||||
@@ -42,103 +45,813 @@ func (i *importer) Import(ctx context.Context, objectKey, fileType string) ([]bo
|
||||
}
|
||||
|
||||
if fileType == "pdf" {
|
||||
return i.parsePDF(data)
|
||||
return parsePDF(data)
|
||||
}
|
||||
return i.parseEPUB(data)
|
||||
return parseEPUB(data)
|
||||
}
|
||||
|
||||
func (i *importer) parsePDF(data []byte) ([]bookstore.Chapter, error) {
|
||||
return nil, errors.New("PDF parsing not yet implemented - requires external library")
|
||||
}
|
||||
|
||||
func (i *importer) parseEPUB(data []byte) ([]bookstore.Chapter, error) {
|
||||
return nil, errors.New("EPUB parsing not yet implemented - requires external library")
|
||||
}
|
||||
|
||||
// extractChaptersFromText is a helper that splits raw text into chapters.
|
||||
// Used as a fallback when the PDF parser library returns raw text.
|
||||
func extractChaptersFromText(text string) []bookstore.Chapter {
|
||||
// AnalyzeFile parses the given PDF or EPUB data and returns the detected
|
||||
// chapter count and up to 3 preview lines (first non-empty line of each of
|
||||
// the first 3 chapters). It is used by the analyze-only endpoint so users
|
||||
// can preview chapter count before committing the import.
|
||||
// Note: uses parsePDF which is backed by pdfcpu ExtractContent — fast, no hang risk.
|
||||
func AnalyzeFile(data []byte, fileType string) (chapterCount int, firstLines []string, err error) {
|
||||
var chapters []bookstore.Chapter
|
||||
var currentChapter *bookstore.Chapter
|
||||
|
||||
lines := strings.Split(text, "\n")
|
||||
chapterNum := 0
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
matches := chapterPattern.FindStringSubmatch(line)
|
||||
if matches != nil {
|
||||
if currentChapter != nil && currentChapter.Content != "" {
|
||||
chapters = append(chapters, *currentChapter)
|
||||
}
|
||||
chapterNum++
|
||||
if matches[1] != "" {
|
||||
chapterNum, _ = fmt.Sscanf(matches[1], "%d", &chapterNum)
|
||||
}
|
||||
currentChapter = &bookstore.Chapter{
|
||||
Number: chapterNum,
|
||||
Title: line,
|
||||
Content: "",
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if currentChapter != nil {
|
||||
if currentChapter.Content != "" {
|
||||
currentChapter.Content += " "
|
||||
}
|
||||
currentChapter.Content += line
|
||||
}
|
||||
switch fileType {
|
||||
case "pdf":
|
||||
chapters, err = parsePDF(data)
|
||||
case "epub":
|
||||
chapters, err = parseEPUB(data)
|
||||
default:
|
||||
return 0, nil, fmt.Errorf("unsupported file type: %s", fileType)
|
||||
}
|
||||
|
||||
if currentChapter != nil && currentChapter.Content != "" {
|
||||
chapters = append(chapters, *currentChapter)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
// If no chapters found via regex, try splitting by double newlines
|
||||
if len(chapters) == 0 {
|
||||
paragraphs := strings.Split(text, "\n\n")
|
||||
for i, para := range paragraphs {
|
||||
para = strings.TrimSpace(para)
|
||||
if len(para) > 50 {
|
||||
chapters = append(chapters, bookstore.Chapter{
|
||||
Number: i + 1,
|
||||
Title: fmt.Sprintf("Chapter %d", i+1),
|
||||
Content: para,
|
||||
})
|
||||
}
|
||||
chapterCount = len(chapters)
|
||||
for i, ch := range chapters {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
line := strings.TrimSpace(ch.Content)
|
||||
if nl := strings.Index(line, "\n"); nl > 0 {
|
||||
line = line[:nl]
|
||||
}
|
||||
if len(line) > 120 {
|
||||
line = line[:120] + "…"
|
||||
}
|
||||
firstLines = append(firstLines, line)
|
||||
}
|
||||
|
||||
return chapters
|
||||
return chapterCount, firstLines, nil
|
||||
}
|
||||
|
||||
// IngestChapters stores extracted chapters for a book via BookWriter.
|
||||
// This is called by the runner after extracting chapters from PDF/EPUB.
|
||||
func (s *Store) IngestChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error {
|
||||
// For now, store each chapter as plain text in MinIO (similar to scraped chapters)
|
||||
// The BookWriter interface expects markdown, so we'll store the content as-is
|
||||
for _, ch := range chapters {
|
||||
content := fmt.Sprintf("# Chapter %d\n\n%s", ch.Number, ch.Content)
|
||||
if ch.Title != "" {
|
||||
content = fmt.Sprintf("# %s\n\n%s", ch.Title, ch.Content)
|
||||
|
||||
|
||||
// decryptPDF strips encryption from a PDF using an empty user password.
|
||||
// Returns the decrypted bytes, or an error if decryption is not possible.
|
||||
// This handles the common case of "owner-only" encrypted PDFs (copy/print
|
||||
// restrictions) which use an empty user password and open normally in readers.
|
||||
func decryptPDF(data []byte) ([]byte, error) {
|
||||
conf := model.NewDefaultConfiguration()
|
||||
conf.UserPW = ""
|
||||
conf.OwnerPW = ""
|
||||
|
||||
var out bytes.Buffer
|
||||
err := api.Decrypt(bytes.NewReader(data), &out, conf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// ParseImportFile parses a PDF or EPUB and returns chapters.
|
||||
// Unlike AnalyzeFile it respects ctx cancellation so callers can apply a timeout.
|
||||
// For PDFs it first attempts to strip encryption with an empty password.
|
||||
func ParseImportFile(ctx context.Context, data []byte, fileType string) ([]bookstore.Chapter, error) {
|
||||
type result struct {
|
||||
chapters []bookstore.Chapter
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
var chapters []bookstore.Chapter
|
||||
var err error
|
||||
switch fileType {
|
||||
case "pdf":
|
||||
chapters, err = parsePDF(data)
|
||||
case "epub":
|
||||
chapters, err = parseEPUB(data)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported file type: %s", fileType)
|
||||
}
|
||||
key := fmt.Sprintf("books/%s/chapters/%d.md", slug, ch.Number)
|
||||
if err := s.mc.putObject(ctx, "books", key, "text/markdown", []byte(content)); err != nil {
|
||||
return fmt.Errorf("put chapter %d: %w", ch.Number, err)
|
||||
ch <- result{chapters, err}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("parse timed out: %w", ctx.Err())
|
||||
case r := <-ch:
|
||||
return r.chapters, r.err
|
||||
}
|
||||
}
|
||||
|
||||
// pdfSkipBookmarks lists bookmark titles that are front/back matter, not story chapters.
|
||||
// These are skipped when building the chapter list.
|
||||
var pdfSkipBookmarks = map[string]bool{
|
||||
"cover": true, "insert": true, "title page": true, "copyright": true,
|
||||
"appendix": true, "color insert": true, "color illustrations": true,
|
||||
}
|
||||
|
||||
// parsePDF extracts text from PDF bytes and returns it as a single chapter.
|
||||
//
|
||||
// The full readable text is returned as one chapter so the admin can manually
|
||||
// split it into chapters via the UI using --- markers.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. Decrypt owner-protected PDFs (empty user password).
|
||||
// 2. Extract raw content streams for every page using pdfcpu ExtractContent.
|
||||
// 3. Concatenate text from all pages in order, skipping front matter
|
||||
// (cover, title page, copyright — typically the first 10 pages).
|
||||
func parsePDF(data []byte) ([]bookstore.Chapter, error) {
|
||||
// Decrypt owner-protected PDFs (empty user password).
|
||||
decrypted, err := decryptPDF(data)
|
||||
if err == nil {
|
||||
data = decrypted
|
||||
}
|
||||
|
||||
conf := model.NewDefaultConfiguration()
|
||||
conf.UserPW = ""
|
||||
conf.OwnerPW = ""
|
||||
|
||||
// Extract all page content streams to a temp directory.
|
||||
tmpDir, err := os.MkdirTemp("", "pdf-extract-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
if err := api.ExtractContent(bytes.NewReader(data), tmpDir, "out", nil, conf); err != nil {
|
||||
return nil, fmt.Errorf("extract PDF content: %w", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(tmpDir)
|
||||
if err != nil || len(entries) == 0 {
|
||||
return nil, fmt.Errorf("PDF has no content pages")
|
||||
}
|
||||
|
||||
// Parse page number from filename and build ordered text map.
|
||||
pageTexts := make(map[int]string, len(entries))
|
||||
maxPage := 0
|
||||
for _, e := range entries {
|
||||
pageNum := pageNumFromFilename(e.Name())
|
||||
if pageNum <= 0 {
|
||||
continue
|
||||
}
|
||||
raw, readErr := os.ReadFile(tmpDir + "/" + e.Name())
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
pageTexts[pageNum] = fixWin1252(extractTextFromContentStream(raw))
|
||||
if pageNum > maxPage {
|
||||
maxPage = pageNum
|
||||
}
|
||||
}
|
||||
|
||||
// Also create a simple metadata entry in the books collection
|
||||
// (in a real implementation, we'd update the existing book or create a placeholder)
|
||||
// Determine front-matter cutoff using bookmarks if available,
|
||||
// otherwise skip the first 10 pages (cover/title/copyright).
|
||||
bodyStart := 1
|
||||
bookmarks, bmErr := api.Bookmarks(bytes.NewReader(data), conf)
|
||||
if bmErr == nil {
|
||||
for _, bm := range bookmarks {
|
||||
title := strings.ToLower(strings.TrimSpace(bm.Title))
|
||||
if !pdfSkipBookmarks[title] && bm.PageFrom > 0 {
|
||||
// First non-front-matter bookmark — body starts here.
|
||||
bodyStart = bm.PageFrom
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if maxPage > 10 {
|
||||
bodyStart = 11
|
||||
}
|
||||
|
||||
// Concatenate all body pages.
|
||||
var sb strings.Builder
|
||||
for p := bodyStart; p <= maxPage; p++ {
|
||||
t := strings.TrimSpace(pageTexts[p])
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(t)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(sb.String())
|
||||
if text == "" {
|
||||
return nil, fmt.Errorf("could not extract any text from PDF")
|
||||
}
|
||||
|
||||
return []bookstore.Chapter{{
|
||||
Number: 1,
|
||||
Title: "Full Text",
|
||||
Content: text,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// pageNumFromFilename extracts the page number from a pdfcpu content-stream
|
||||
// filename like "out_Content_page_42.txt". Returns 0 if not parseable.
|
||||
func pageNumFromFilename(name string) int {
|
||||
// Strip directory prefix and extension.
|
||||
base := name
|
||||
if idx := strings.LastIndex(base, "/"); idx >= 0 {
|
||||
base = base[idx+1:]
|
||||
}
|
||||
if idx := strings.LastIndex(base, "."); idx >= 0 {
|
||||
base = base[:idx]
|
||||
}
|
||||
// Find last "_" and parse the number after it.
|
||||
if idx := strings.LastIndex(base, "_"); idx >= 0 {
|
||||
n, err := strconv.Atoi(base[idx+1:])
|
||||
if err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// win1252ToUnicode maps the Windows-1252 control range 0x80–0x9F to the
|
||||
// Unicode characters they actually represent in that encoding.
|
||||
// Standard Latin-1 maps these bytes to control characters; Win-1252 maps
|
||||
// them to typographic symbols that appear in publisher PDFs.
|
||||
var win1252ToUnicode = map[byte]rune{
|
||||
0x80: '\u20AC', // €
|
||||
0x82: '\u201A', // ‚
|
||||
0x83: '\u0192', // ƒ
|
||||
0x84: '\u201E', // „
|
||||
0x85: '\u2026', // …
|
||||
0x86: '\u2020', // †
|
||||
0x87: '\u2021', // ‡
|
||||
0x88: '\u02C6', // ˆ
|
||||
0x89: '\u2030', // ‰
|
||||
0x8A: '\u0160', // Š
|
||||
0x8B: '\u2039', // ‹
|
||||
0x8C: '\u0152', // Œ
|
||||
0x8E: '\u017D', // Ž
|
||||
0x91: '\u2018', // ' (left single quotation mark)
|
||||
0x92: '\u2019', // ' (right single quotation mark / apostrophe)
|
||||
0x93: '\u201C', // " (left double quotation mark)
|
||||
0x94: '\u201D', // " (right double quotation mark)
|
||||
0x95: '\u2022', // • (bullet)
|
||||
0x96: '\u2013', // – (en dash)
|
||||
0x97: '\u2014', // — (em dash)
|
||||
0x98: '\u02DC', // ˜
|
||||
0x99: '\u2122', // ™
|
||||
0x9A: '\u0161', // š
|
||||
0x9B: '\u203A', // ›
|
||||
0x9C: '\u0153', // œ
|
||||
0x9E: '\u017E', // ž
|
||||
0x9F: '\u0178', // Ÿ
|
||||
}
|
||||
|
||||
// fixWin1252 replaces Windows-1252 specific bytes (0x80–0x9F) in a string
|
||||
// that was decoded as raw Latin-1 bytes with their proper Unicode equivalents.
|
||||
func fixWin1252(s string) string {
|
||||
// Fast path: if no bytes in 0x80–0x9F range, return unchanged.
|
||||
needsFix := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
b := s[i]
|
||||
if b >= 0x80 && b <= 0x9F {
|
||||
needsFix = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !needsFix {
|
||||
return s
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
b := s[i]
|
||||
if b >= 0x80 && b <= 0x9F {
|
||||
if r, ok := win1252ToUnicode[b]; ok {
|
||||
sb.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
}
|
||||
sb.WriteByte(b)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// extractTextFromContentStream parses a raw PDF content stream and extracts
|
||||
// readable text from Tj and TJ operators.
|
||||
//
|
||||
// TJ arrays may contain a mix of literal strings (parenthesised) and hex glyph
|
||||
// arrays. Only the literal strings are decoded — hex arrays require per-font
|
||||
// ToUnicode CMaps and are skipped. Kerning adjustment numbers inside TJ arrays
|
||||
// are also ignored (they're just spacing hints).
|
||||
//
|
||||
// Line breaks are inserted on ET / Td / TD / T* operators.
|
||||
func extractTextFromContentStream(stream []byte) string {
|
||||
s := string(stream)
|
||||
var sb strings.Builder
|
||||
i := 0
|
||||
n := len(s)
|
||||
for i < n {
|
||||
// TJ array: [ ... ]TJ — collect all literal strings, skip hex & numbers.
|
||||
if s[i] == '[' {
|
||||
j := i + 1
|
||||
for j < n && s[j] != ']' {
|
||||
if s[j] == '(' {
|
||||
// Literal string inside TJ array.
|
||||
k := j + 1
|
||||
depth := 1
|
||||
for k < n && depth > 0 {
|
||||
if s[k] == '\\' {
|
||||
k += 2
|
||||
continue
|
||||
}
|
||||
if s[k] == '(' {
|
||||
depth++
|
||||
} else if s[k] == ')' {
|
||||
depth--
|
||||
}
|
||||
k++
|
||||
}
|
||||
lit := pdfUnescapeString(s[j+1 : k-1])
|
||||
if hasPrintableASCII(lit) {
|
||||
sb.WriteString(lit)
|
||||
}
|
||||
j = k
|
||||
continue
|
||||
}
|
||||
j++
|
||||
}
|
||||
// Check if this is a TJ operator (skip whitespace after ']').
|
||||
end := j + 1
|
||||
for end < n && (s[end] == ' ' || s[end] == '\t' || s[end] == '\r' || s[end] == '\n') {
|
||||
end++
|
||||
}
|
||||
if end+2 <= n && s[end:end+2] == "TJ" && (end+2 == n || !isAlphaNum(s[end+2])) {
|
||||
i = end + 2
|
||||
continue
|
||||
}
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
// Single string: (string) Tj
|
||||
if s[i] == '(' {
|
||||
j := i + 1
|
||||
depth := 1
|
||||
for j < n && depth > 0 {
|
||||
if s[j] == '\\' {
|
||||
j += 2
|
||||
continue
|
||||
}
|
||||
if s[j] == '(' {
|
||||
depth++
|
||||
} else if s[j] == ')' {
|
||||
depth--
|
||||
}
|
||||
j++
|
||||
}
|
||||
lit := pdfUnescapeString(s[i+1 : j-1])
|
||||
if hasPrintableASCII(lit) {
|
||||
// Check for Tj operator.
|
||||
end := j
|
||||
for end < n && (s[end] == ' ' || s[end] == '\t') {
|
||||
end++
|
||||
}
|
||||
if end+2 <= n && s[end:end+2] == "Tj" && (end+2 == n || !isAlphaNum(s[end+2])) {
|
||||
sb.WriteString(lit)
|
||||
i = end + 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
// Detect end of text object (ET) — add a newline.
|
||||
if i+2 <= n && s[i:i+2] == "ET" && (i+2 == n || !isAlphaNum(s[i+2])) {
|
||||
sb.WriteByte('\n')
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
// Detect Td / TD / T* — newline within text block.
|
||||
if i+2 <= n && (s[i:i+2] == "Td" || s[i:i+2] == "TD" || s[i:i+2] == "T*") &&
|
||||
(i+2 == n || !isAlphaNum(s[i+2])) {
|
||||
sb.WriteByte('\n')
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func isAlphaNum(b byte) bool {
|
||||
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
|
||||
}
|
||||
|
||||
func hasPrintableASCII(s string) bool {
|
||||
for _, c := range s {
|
||||
if c >= 0x20 && c < 0x7F {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pdfUnescapeString handles PDF string escape sequences.
|
||||
func pdfUnescapeString(s string) string {
|
||||
if !strings.ContainsRune(s, '\\') {
|
||||
return s
|
||||
}
|
||||
var sb strings.Builder
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
if s[i] == '\\' && i+1 < len(s) {
|
||||
switch s[i+1] {
|
||||
case 'n':
|
||||
sb.WriteByte('\n')
|
||||
case 'r':
|
||||
sb.WriteByte('\r')
|
||||
case 't':
|
||||
sb.WriteByte('\t')
|
||||
case '(', ')', '\\':
|
||||
sb.WriteByte(s[i+1])
|
||||
default:
|
||||
// Octal escape \ddd
|
||||
if s[i+1] >= '0' && s[i+1] <= '7' {
|
||||
end := i + 2
|
||||
for end < i+5 && end < len(s) && s[end] >= '0' && s[end] <= '7' {
|
||||
end++
|
||||
}
|
||||
val, _ := strconv.ParseInt(s[i+1:end], 8, 16)
|
||||
sb.WriteByte(byte(val))
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
sb.WriteByte(s[i+1])
|
||||
}
|
||||
i += 2
|
||||
} else {
|
||||
sb.WriteByte(s[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── EPUB parsing ──────────────────────────────────────────────────────────────
|
||||
|
||||
func parseEPUB(data []byte) ([]bookstore.Chapter, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open EPUB zip: %w", err)
|
||||
}
|
||||
|
||||
// 1. Read META-INF/container.xml → find rootfile (content.opf path).
|
||||
opfPath, err := epubRootfilePath(zr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("epub container: %w", err)
|
||||
}
|
||||
|
||||
// 2. Parse content.opf → spine order of chapter files.
|
||||
spineFiles, titleMap, err := epubSpine(zr, opfPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("epub spine: %w", err)
|
||||
}
|
||||
|
||||
if len(spineFiles) == 0 {
|
||||
return nil, fmt.Errorf("EPUB spine is empty")
|
||||
}
|
||||
|
||||
// Base directory of the OPF file for resolving relative hrefs.
|
||||
opfDir := ""
|
||||
if idx := strings.LastIndex(opfPath, "/"); idx >= 0 {
|
||||
opfDir = opfPath[:idx+1]
|
||||
}
|
||||
|
||||
var chapters []bookstore.Chapter
|
||||
chNum := 0
|
||||
for i, href := range spineFiles {
|
||||
fullPath := opfDir + href
|
||||
content, err := epubFileContent(zr, fullPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
text := htmlToText(content)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
continue
|
||||
}
|
||||
chNum++
|
||||
title := titleMap[href]
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Chapter %d", chNum)
|
||||
}
|
||||
_ = i // spine index unused for numbering
|
||||
chapters = append(chapters, bookstore.Chapter{
|
||||
Number: chNum,
|
||||
Title: title,
|
||||
Content: text,
|
||||
})
|
||||
}
|
||||
|
||||
if len(chapters) == 0 {
|
||||
return nil, fmt.Errorf("no readable chapters found in EPUB")
|
||||
}
|
||||
return chapters, nil
|
||||
}
|
||||
|
||||
// epubRootfilePath parses META-INF/container.xml and returns the full-path
|
||||
// of the OPF package document.
|
||||
func epubRootfilePath(zr *zip.Reader) (string, error) {
|
||||
f := zipFile(zr, "META-INF/container.xml")
|
||||
if f == nil {
|
||||
return "", fmt.Errorf("META-INF/container.xml not found")
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
doc, err := html.Parse(rc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var path string
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && strings.EqualFold(n.Data, "rootfile") {
|
||||
for _, a := range n.Attr {
|
||||
if strings.EqualFold(a.Key, "full-path") {
|
||||
path = a.Val
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("rootfile full-path not found in container.xml")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// epubSpine parses the OPF document and returns the spine item hrefs in order,
|
||||
// plus a map from href → nav title (if available from NCX/NAV).
|
||||
func epubSpine(zr *zip.Reader, opfPath string) ([]string, map[string]string, error) {
|
||||
f := zipFile(zr, opfPath)
|
||||
if f == nil {
|
||||
return nil, nil, fmt.Errorf("OPF file %q not found in EPUB", opfPath)
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
opfData, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Build id→href map from <manifest>.
|
||||
idToHref := make(map[string]string)
|
||||
// Also keep a href→navTitle map (populated from NCX later).
|
||||
hrefTitle := make(map[string]string)
|
||||
|
||||
// Parse OPF XML with html.Parse (handles malformed XML too).
|
||||
doc, _ := html.Parse(bytes.NewReader(opfData))
|
||||
|
||||
var manifestItems []struct{ id, href, mediaType string }
|
||||
var spineIdrefs []string
|
||||
var ncxID string
|
||||
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode {
|
||||
tag := strings.ToLower(n.Data)
|
||||
switch tag {
|
||||
case "item":
|
||||
var id, href, mt string
|
||||
for _, a := range n.Attr {
|
||||
switch strings.ToLower(a.Key) {
|
||||
case "id":
|
||||
id = a.Val
|
||||
case "href":
|
||||
href = a.Val
|
||||
case "media-type":
|
||||
mt = a.Val
|
||||
}
|
||||
}
|
||||
if id != "" && href != "" {
|
||||
manifestItems = append(manifestItems, struct{ id, href, mediaType string }{id, href, mt})
|
||||
idToHref[id] = href
|
||||
}
|
||||
case "itemref":
|
||||
for _, a := range n.Attr {
|
||||
if strings.ToLower(a.Key) == "idref" {
|
||||
spineIdrefs = append(spineIdrefs, a.Val)
|
||||
}
|
||||
}
|
||||
case "spine":
|
||||
for _, a := range n.Attr {
|
||||
if strings.ToLower(a.Key) == "toc" {
|
||||
ncxID = a.Val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
|
||||
// Build ordered spine href list.
|
||||
var spineHrefs []string
|
||||
for _, idref := range spineIdrefs {
|
||||
if href, ok := idToHref[idref]; ok {
|
||||
spineHrefs = append(spineHrefs, href)
|
||||
}
|
||||
}
|
||||
|
||||
// If no explicit spine, fall back to all XHTML items in manifest order.
|
||||
if len(spineHrefs) == 0 {
|
||||
sort.Slice(manifestItems, func(i, j int) bool {
|
||||
return manifestItems[i].href < manifestItems[j].href
|
||||
})
|
||||
for _, it := range manifestItems {
|
||||
mt := strings.ToLower(it.mediaType)
|
||||
if strings.Contains(mt, "html") || strings.HasSuffix(strings.ToLower(it.href), ".html") || strings.HasSuffix(strings.ToLower(it.href), ".xhtml") {
|
||||
spineHrefs = append(spineHrefs, it.href)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get chapter titles from NCX (toc.ncx).
|
||||
opfDir := ""
|
||||
if idx := strings.LastIndex(opfPath, "/"); idx >= 0 {
|
||||
opfDir = opfPath[:idx+1]
|
||||
}
|
||||
if ncxHref, ok := idToHref[ncxID]; ok {
|
||||
ncxPath := opfDir + ncxHref
|
||||
if ncxFile := zipFile(zr, ncxPath); ncxFile != nil {
|
||||
if ncxRC, err := ncxFile.Open(); err == nil {
|
||||
defer ncxRC.Close()
|
||||
parseNCXTitles(ncxRC, hrefTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return spineHrefs, hrefTitle, nil
|
||||
}
|
||||
|
||||
// parseNCXTitles extracts navPoint label→src mappings from a toc.ncx.
|
||||
func parseNCXTitles(r io.Reader, out map[string]string) {
|
||||
doc, err := html.Parse(r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Collect navPoints: each has a <navLabel><text>…</text></navLabel> and
|
||||
// a <content src="…"/> child.
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && strings.EqualFold(n.Data, "navpoint") {
|
||||
var label, src string
|
||||
var inner func(*html.Node)
|
||||
inner = func(c *html.Node) {
|
||||
if c.Type == html.ElementNode {
|
||||
if strings.EqualFold(c.Data, "text") && label == "" {
|
||||
if c.FirstChild != nil && c.FirstChild.Type == html.TextNode {
|
||||
label = strings.TrimSpace(c.FirstChild.Data)
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(c.Data, "content") {
|
||||
for _, a := range c.Attr {
|
||||
if strings.EqualFold(a.Key, "src") {
|
||||
// Strip fragment identifier (#...).
|
||||
src = strings.SplitN(a.Val, "#", 2)[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for child := c.FirstChild; child != nil; child = child.NextSibling {
|
||||
inner(child)
|
||||
}
|
||||
}
|
||||
inner(n)
|
||||
if label != "" && src != "" {
|
||||
out[src] = label
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
}
|
||||
|
||||
// epubFileContent returns the raw bytes of a file inside the EPUB zip.
|
||||
func epubFileContent(zr *zip.Reader, path string) ([]byte, error) {
|
||||
f := zipFile(zr, path)
|
||||
if f == nil {
|
||||
return nil, fmt.Errorf("file %q not in EPUB", path)
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// zipFile finds a file by name (case-insensitive) in a zip.Reader.
|
||||
func zipFile(zr *zip.Reader, name string) *zip.File {
|
||||
nameLower := strings.ToLower(name)
|
||||
for _, f := range zr.File {
|
||||
if strings.ToLower(f.Name) == nameLower {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// htmlToText converts HTML/XHTML content to plain text suitable for storage.
|
||||
func htmlToText(data []byte) string {
|
||||
doc, err := html.Parse(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.TextNode {
|
||||
text := strings.TrimSpace(n.Data)
|
||||
if text != "" {
|
||||
sb.WriteString(text)
|
||||
sb.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
if n.Type == html.ElementNode {
|
||||
switch strings.ToLower(n.Data) {
|
||||
case "p", "div", "br", "h1", "h2", "h3", "h4", "h5", "h6", "li", "tr":
|
||||
// Block-level: ensure newline before content.
|
||||
if sb.Len() > 0 {
|
||||
s := sb.String()
|
||||
if s[len(s)-1] != '\n' {
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
case "script", "style", "head":
|
||||
// Skip entirely.
|
||||
return
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
if n.Type == html.ElementNode {
|
||||
switch strings.ToLower(n.Data) {
|
||||
case "p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "tr":
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
|
||||
// Collapse multiple blank lines.
|
||||
lines := strings.Split(sb.String(), "\n")
|
||||
var out []string
|
||||
blanks := 0
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(l)
|
||||
if l == "" {
|
||||
blanks++
|
||||
if blanks <= 1 {
|
||||
out = append(out, "")
|
||||
}
|
||||
} else {
|
||||
blanks = 0
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||
}
|
||||
|
||||
// ── Chapter ingestion ─────────────────────────────────────────────────────────
|
||||
|
||||
// IngestChapters stores extracted chapters for a book.
|
||||
// Each chapter is written as a markdown file in the chapters MinIO bucket
|
||||
// and its index record is upserted in PocketBase via WriteChapter.
|
||||
func (s *Store) IngestChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error {
|
||||
for _, ch := range chapters {
|
||||
var mdContent string
|
||||
if ch.Title != "" && ch.Title != fmt.Sprintf("Chapter %d", ch.Number) {
|
||||
mdContent = fmt.Sprintf("# %s\n\n%s", ch.Title, ch.Content)
|
||||
} else {
|
||||
mdContent = fmt.Sprintf("# Chapter %d\n\n%s", ch.Number, ch.Content)
|
||||
}
|
||||
domainCh := domain.Chapter{
|
||||
Ref: domain.ChapterRef{Number: ch.Number, Title: ch.Title},
|
||||
Text: mdContent,
|
||||
}
|
||||
if err := s.WriteChapter(ctx, slug, domainCh); err != nil {
|
||||
return fmt.Errorf("ingest chapter %d: %w", ch.Number, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetImportObjectKey returns the MinIO object key for an uploaded import file.
|
||||
func GetImportObjectKey(filename string) string {
|
||||
return fmt.Sprintf("imports/%s", filename)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ var _ bookstore.CoverStore = (*Store)(nil)
|
||||
var _ bookstore.TranslationStore = (*Store)(nil)
|
||||
var _ bookstore.AIJobStore = (*Store)(nil)
|
||||
var _ bookstore.ChapterImageStore = (*Store)(nil)
|
||||
var _ bookstore.BookAdminStore = (*Store)(nil)
|
||||
var _ taskqueue.Producer = (*Store)(nil)
|
||||
var _ taskqueue.Consumer = (*Store)(nil)
|
||||
var _ taskqueue.Reader = (*Store)(nil)
|
||||
@@ -62,7 +63,8 @@ var _ taskqueue.Reader = (*Store)(nil)
|
||||
// ── BookWriter ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
|
||||
payload := map[string]any{
|
||||
// patchPayload does NOT include visibility or submitted_by — preserve existing values.
|
||||
patchPayload := map[string]any{
|
||||
"slug": meta.Slug,
|
||||
"title": meta.Title,
|
||||
"author": meta.Author,
|
||||
@@ -84,7 +86,13 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
|
||||
return fmt.Errorf("WriteMetadata: %w", err)
|
||||
}
|
||||
if err == ErrNotFound {
|
||||
postErr := s.pb.post(ctx, "/api/collections/books/records", payload, nil)
|
||||
// New scraped book — default to admin_only visibility.
|
||||
postPayload := make(map[string]any, len(patchPayload)+1)
|
||||
for k, v := range patchPayload {
|
||||
postPayload[k] = v
|
||||
}
|
||||
postPayload["visibility"] = domain.VisibilityAdminOnly
|
||||
postErr := s.pb.post(ctx, "/api/collections/books/records", postPayload, nil)
|
||||
if postErr == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -95,7 +103,28 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
|
||||
return postErr // original POST error is more informative
|
||||
}
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), payload)
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), patchPayload)
|
||||
}
|
||||
|
||||
// CreateSubmittedBook creates a new author-submitted book with visibility=public.
|
||||
// Unlike WriteMetadata this always POSTs (no upsert) and sets the submitted_by field.
|
||||
func (s *Store) CreateSubmittedBook(ctx context.Context, meta domain.BookMeta) error {
|
||||
payload := map[string]any{
|
||||
"slug": meta.Slug,
|
||||
"title": meta.Title,
|
||||
"author": meta.Author,
|
||||
"cover": meta.Cover,
|
||||
"status": meta.Status,
|
||||
"genres": meta.Genres,
|
||||
"summary": meta.Summary,
|
||||
"total_chapters": 0,
|
||||
"source_url": "",
|
||||
"ranking": 0,
|
||||
"rating": 0,
|
||||
"visibility": domain.VisibilityPublic,
|
||||
"submitted_by": meta.SubmittedBy,
|
||||
}
|
||||
return s.pb.post(ctx, "/api/collections/books/records", payload, nil)
|
||||
}
|
||||
|
||||
func (s *Store) WriteChapter(ctx context.Context, slug string, chapter domain.Chapter) error {
|
||||
@@ -226,6 +255,9 @@ type pbBook struct {
|
||||
Ranking int `json:"ranking"`
|
||||
Rating float64 `json:"rating"`
|
||||
Updated string `json:"updated"`
|
||||
Archived bool `json:"archived"`
|
||||
Visibility string `json:"visibility"`
|
||||
SubmittedBy string `json:"submitted_by"`
|
||||
}
|
||||
|
||||
func (b pbBook) toDomain() domain.BookMeta {
|
||||
@@ -246,6 +278,9 @@ func (b pbBook) toDomain() domain.BookMeta {
|
||||
Ranking: b.Ranking,
|
||||
Rating: b.Rating,
|
||||
MetaUpdated: metaUpdated,
|
||||
Archived: b.Archived,
|
||||
Visibility: b.Visibility,
|
||||
SubmittedBy: b.SubmittedBy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +310,7 @@ func (s *Store) ReadMetadata(ctx context.Context, slug string) (domain.BookMeta,
|
||||
}
|
||||
|
||||
func (s *Store) ListBooks(ctx context.Context) ([]domain.BookMeta, error) {
|
||||
items, err := s.pb.listAll(ctx, "books", "", "title")
|
||||
items, err := s.pb.listAll(ctx, "books", "archived=false", "title")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -376,6 +411,110 @@ func (s *Store) ReindexChapters(ctx context.Context, slug string) (int, error) {
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ── BookAdminStore ────────────────────────────────────────────────────────────
|
||||
|
||||
// ArchiveBook sets archived=true on the book record for slug.
|
||||
func (s *Store) ArchiveBook(ctx context.Context, slug string) error {
|
||||
book, err := s.getBookBySlug(ctx, slug)
|
||||
if err == ErrNotFound {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("ArchiveBook: %w", err)
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
|
||||
map[string]any{"archived": true})
|
||||
}
|
||||
|
||||
// UnarchiveBook clears archived on the book record for slug.
|
||||
func (s *Store) UnarchiveBook(ctx context.Context, slug string) error {
|
||||
book, err := s.getBookBySlug(ctx, slug)
|
||||
if err == ErrNotFound {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("UnarchiveBook: %w", err)
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
|
||||
map[string]any{"archived": false})
|
||||
}
|
||||
|
||||
// PublishBook sets visibility=public on the book record for slug.
|
||||
func (s *Store) PublishBook(ctx context.Context, slug string) error {
|
||||
book, err := s.getBookBySlug(ctx, slug)
|
||||
if err == ErrNotFound {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("PublishBook: %w", err)
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
|
||||
map[string]any{"visibility": domain.VisibilityPublic})
|
||||
}
|
||||
|
||||
// UnpublishBook sets visibility=admin_only on the book record for slug.
|
||||
func (s *Store) UnpublishBook(ctx context.Context, slug string) error {
|
||||
book, err := s.getBookBySlug(ctx, slug)
|
||||
if err == ErrNotFound {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("UnpublishBook: %w", err)
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID),
|
||||
map[string]any{"visibility": domain.VisibilityAdminOnly})
|
||||
}
|
||||
|
||||
// DeleteBook permanently removes all data for a book:
|
||||
// - PocketBase books record
|
||||
// - All PocketBase chapters_idx records for the slug
|
||||
// - All MinIO chapter markdown objects ({slug}/chapter-*.md)
|
||||
// - MinIO cover image (covers/{slug}.jpg)
|
||||
func (s *Store) DeleteBook(ctx context.Context, slug string) error {
|
||||
// 1. Fetch the book record to get its PocketBase ID.
|
||||
book, err := s.getBookBySlug(ctx, slug)
|
||||
if err == ErrNotFound {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("DeleteBook: fetch: %w", err)
|
||||
}
|
||||
|
||||
// 2. Delete all chapters_idx records.
|
||||
filter := fmt.Sprintf(`slug=%q`, slug)
|
||||
items, err := s.pb.listAll(ctx, "chapters_idx", filter, "")
|
||||
if err != nil && err != ErrNotFound {
|
||||
return fmt.Errorf("DeleteBook: list chapters_idx: %w", err)
|
||||
}
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
|
||||
if delErr := s.pb.delete(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID)); delErr != nil {
|
||||
s.log.Warn("DeleteBook: delete chapters_idx record failed", "slug", slug, "id", rec.ID, "err", delErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Delete MinIO chapter objects.
|
||||
if err := s.mc.deleteObjects(ctx, s.mc.bucketChapters, slug+"/"); err != nil {
|
||||
s.log.Warn("DeleteBook: delete chapter objects failed", "slug", slug, "err", err)
|
||||
}
|
||||
|
||||
// 4. Delete MinIO cover image.
|
||||
if err := s.mc.deleteObjects(ctx, s.mc.bucketBrowse, CoverObjectKey(slug)); err != nil {
|
||||
s.log.Warn("DeleteBook: delete cover failed", "slug", slug, "err", err)
|
||||
}
|
||||
|
||||
// 5. Delete the PocketBase books record.
|
||||
if err := s.pb.delete(ctx, fmt.Sprintf("/api/collections/books/records/%s", book.ID)); err != nil {
|
||||
return fmt.Errorf("DeleteBook: delete books record: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── RankingStore ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) WriteRankingItem(ctx context.Context, item domain.RankingItem) error {
|
||||
@@ -647,17 +786,26 @@ func (s *Store) CreateTranslationTask(ctx context.Context, slug string, chapter
|
||||
return rec.ID, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error) {
|
||||
func (s *Store) CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error) {
|
||||
payload := map[string]any{
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"file_name": slug + "." + fileType,
|
||||
"file_type": fileType,
|
||||
"slug": task.Slug,
|
||||
"title": task.Title,
|
||||
"file_name": task.Slug + "." + task.FileType,
|
||||
"file_type": task.FileType,
|
||||
"object_key": task.ObjectKey,
|
||||
"chapters_key": task.ChaptersKey,
|
||||
"author": task.Author,
|
||||
"cover_url": task.CoverURL,
|
||||
"summary": task.Summary,
|
||||
"book_status": task.BookStatus,
|
||||
"status": string(domain.TaskStatusPending),
|
||||
"chapters_done": 0,
|
||||
"chapters_total": 0,
|
||||
"chapters_total": task.ChaptersTotal,
|
||||
"started": time.Now().UTC().Format(time.RFC3339),
|
||||
"initiator_user_id": initiatorUserID,
|
||||
"initiator_user_id": task.InitiatorUserID,
|
||||
}
|
||||
if len(task.Genres) > 0 {
|
||||
payload["genres"] = strings.Join(task.Genres, ",")
|
||||
}
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
@@ -683,7 +831,7 @@ func (s *Store) CreateNotification(ctx context.Context, userID, title, message,
|
||||
|
||||
// ListNotifications returns notifications for a user.
|
||||
func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error) {
|
||||
filter := fmt.Sprintf("user_id='%s'", userID)
|
||||
filter := fmt.Sprintf(`user_id=%q`, userID)
|
||||
items, err := s.pb.listAll(ctx, "notifications", filter, "-created")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -708,6 +856,48 @@ func (s *Store) MarkNotificationRead(ctx context.Context, id string) error {
|
||||
map[string]any{"read": true})
|
||||
}
|
||||
|
||||
// DeleteNotification deletes a single notification by ID.
|
||||
func (s *Store) DeleteNotification(ctx context.Context, id string) error {
|
||||
return s.pb.delete(ctx, fmt.Sprintf("/api/collections/notifications/records/%s", id))
|
||||
}
|
||||
|
||||
// ClearAllNotifications deletes all notifications for a user.
|
||||
func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error {
|
||||
filter := fmt.Sprintf(`user_id=%q`, userID)
|
||||
items, err := s.pb.listAll(ctx, "notifications", filter, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ClearAllNotifications list: %w", err)
|
||||
}
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
|
||||
_ = s.pb.delete(ctx, fmt.Sprintf("/api/collections/notifications/records/%s", rec.ID))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllNotificationsRead marks all notifications for a user as read.
|
||||
func (s *Store) MarkAllNotificationsRead(ctx context.Context, userID string) error {
|
||||
filter := fmt.Sprintf(`user_id=%q&&read=false`, userID)
|
||||
items, err := s.pb.listAll(ctx, "notifications", filter, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("MarkAllNotificationsRead list: %w", err)
|
||||
}
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
|
||||
_ = s.pb.patch(ctx, fmt.Sprintf("/api/collections/notifications/records/%s", rec.ID),
|
||||
map[string]any{"read": true})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CancelTask(ctx context.Context, id string) error {
|
||||
// Try scraping_tasks first, then audio_jobs, then translation_jobs.
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id),
|
||||
@@ -864,7 +1054,7 @@ func (s *Store) FailTask(ctx context.Context, id, errMsg string) error {
|
||||
}
|
||||
|
||||
// HeartbeatTask updates the heartbeat_at field on a running task.
|
||||
// Tries scraping_tasks first, then audio_jobs, then translation_jobs.
|
||||
// Tries scraping_tasks, audio_jobs, translation_jobs, then import_tasks.
|
||||
func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
|
||||
payload := map[string]any{
|
||||
"heartbeat_at": time.Now().UTC().Format(time.RFC3339),
|
||||
@@ -875,7 +1065,10 @@ func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), payload)
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), payload); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/import_tasks/records/%s", id), payload)
|
||||
}
|
||||
|
||||
// ReapStaleTasks finds all running tasks whose heartbeat_at is either missing
|
||||
@@ -893,7 +1086,7 @@ func (s *Store) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (i
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, collection := range []string{"scraping_tasks", "audio_jobs", "translation_jobs"} {
|
||||
for _, collection := range []string{"scraping_tasks", "audio_jobs", "translation_jobs", "import_tasks"} {
|
||||
items, err := s.pb.listAll(ctx, collection, filter, "")
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("ReapStaleTasks list %s: %w", collection, err)
|
||||
@@ -1134,6 +1327,13 @@ func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
|
||||
Title string `json:"title"`
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
ChaptersKey string `json:"chapters_key"`
|
||||
Author string `json:"author"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Genres string `json:"genres"` // stored as comma-separated
|
||||
Summary string `json:"summary"`
|
||||
BookStatus string `json:"book_status"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
InitiatorUserID string `json:"initiator_user_id"`
|
||||
Status string `json:"status"`
|
||||
@@ -1148,12 +1348,27 @@ func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
|
||||
}
|
||||
started, _ := time.Parse(time.RFC3339, rec.Started)
|
||||
finished, _ := time.Parse(time.RFC3339, rec.Finished)
|
||||
var genres []string
|
||||
if rec.Genres != "" {
|
||||
for _, g := range strings.Split(rec.Genres, ",") {
|
||||
if g = strings.TrimSpace(g); g != "" {
|
||||
genres = append(genres, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
return domain.ImportTask{
|
||||
ID: rec.ID,
|
||||
Slug: rec.Slug,
|
||||
Title: rec.Title,
|
||||
FileName: rec.FileName,
|
||||
FileType: rec.FileType,
|
||||
ObjectKey: rec.ObjectKey,
|
||||
ChaptersKey: rec.ChaptersKey,
|
||||
Author: rec.Author,
|
||||
CoverURL: rec.CoverURL,
|
||||
Genres: genres,
|
||||
Summary: rec.Summary,
|
||||
BookStatus: rec.BookStatus,
|
||||
WorkerID: rec.WorkerID,
|
||||
InitiatorUserID: rec.InitiatorUserID,
|
||||
Status: domain.TaskStatus(rec.Status),
|
||||
@@ -1196,6 +1411,20 @@ func (s *Store) PutImportFile(ctx context.Context, key string, data []byte) erro
|
||||
return s.mc.putObject(ctx, "imports", key, "application/octet-stream", data)
|
||||
}
|
||||
|
||||
// PutImportChapters stores a pre-parsed chapters JSON blob in MinIO.
|
||||
func (s *Store) PutImportChapters(ctx context.Context, key string, data []byte) error {
|
||||
return s.mc.putObject(ctx, "imports", key, "application/json", data)
|
||||
}
|
||||
|
||||
// GetImportChapters retrieves the pre-parsed chapters JSON from MinIO.
|
||||
func (s *Store) GetImportChapters(ctx context.Context, key string) ([]byte, error) {
|
||||
data, err := s.mc.getObject(ctx, "imports", key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get chapters object: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *Store) CoverExists(ctx context.Context, slug string) bool {
|
||||
return s.mc.coverExists(ctx, CoverObjectKey(slug))
|
||||
}
|
||||
@@ -1355,3 +1584,169 @@ func parseAIJob(raw json.RawMessage) (domain.AIJob, error) {
|
||||
HeartbeatAt: parseT(r.HeartbeatAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── Push subscriptions ────────────────────────────────────────────────────────
|
||||
|
||||
// PushSubscription holds the Web Push subscription data for a single browser.
|
||||
type PushSubscription struct {
|
||||
ID string
|
||||
UserID string
|
||||
Endpoint string
|
||||
P256DH string
|
||||
Auth string
|
||||
}
|
||||
|
||||
// SavePushSubscription upserts a Web Push subscription for a user.
|
||||
// If a record with the same endpoint already exists it is updated in place.
|
||||
func (s *Store) SavePushSubscription(ctx context.Context, sub PushSubscription) error {
|
||||
filter := fmt.Sprintf("endpoint=%q", sub.Endpoint)
|
||||
existing, err := s.pb.listAll(ctx, "push_subscriptions", filter, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("SavePushSubscription list: %w", err)
|
||||
}
|
||||
payload := map[string]any{
|
||||
"user_id": sub.UserID,
|
||||
"endpoint": sub.Endpoint,
|
||||
"p256dh": sub.P256DH,
|
||||
"auth": sub.Auth,
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(existing[0], &rec) == nil && rec.ID != "" {
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/push_subscriptions/records/%s", rec.ID), payload)
|
||||
}
|
||||
}
|
||||
return s.pb.post(ctx, "/api/collections/push_subscriptions/records", payload, nil)
|
||||
}
|
||||
|
||||
// DeletePushSubscription removes a Web Push subscription by endpoint.
|
||||
func (s *Store) DeletePushSubscription(ctx context.Context, userID, endpoint string) error {
|
||||
filter := fmt.Sprintf("user_id=%q&&endpoint=%q", userID, endpoint)
|
||||
items, err := s.pb.listAll(ctx, "push_subscriptions", filter, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("DeletePushSubscription list: %w", err)
|
||||
}
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
|
||||
_ = s.pb.delete(ctx, fmt.Sprintf("/api/collections/push_subscriptions/records/%s", rec.ID))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPushSubscriptionsByBook returns all push subscriptions belonging to users
|
||||
// who have the given book slug in their library (user_library collection).
|
||||
func (s *Store) ListPushSubscriptionsByBook(ctx context.Context, slug string) ([]PushSubscription, error) {
|
||||
// Find all users who have this book in their library
|
||||
libFilter := fmt.Sprintf("slug=%q&&user_id!=''", slug)
|
||||
libItems, err := s.pb.listAll(ctx, "user_library", libFilter, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ListPushSubscriptionsByBook list library: %w", err)
|
||||
}
|
||||
|
||||
// Collect unique user IDs
|
||||
seen := make(map[string]bool)
|
||||
var userIDs []string
|
||||
for _, raw := range libItems {
|
||||
var rec struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.UserID != "" && !seen[rec.UserID] {
|
||||
seen[rec.UserID] = true
|
||||
userIDs = append(userIDs, rec.UserID)
|
||||
}
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build OR filter for push_subscriptions
|
||||
parts := make([]string, len(userIDs))
|
||||
for i, uid := range userIDs {
|
||||
parts[i] = fmt.Sprintf("user_id=%q", uid)
|
||||
}
|
||||
subFilter := strings.Join(parts, "||")
|
||||
subItems, err := s.pb.listAll(ctx, "push_subscriptions", subFilter, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ListPushSubscriptionsByBook list subs: %w", err)
|
||||
}
|
||||
|
||||
subs := make([]PushSubscription, 0, len(subItems))
|
||||
for _, raw := range subItems {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
P256DH string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.Endpoint != "" {
|
||||
subs = append(subs, PushSubscription{
|
||||
ID: rec.ID,
|
||||
UserID: rec.UserID,
|
||||
Endpoint: rec.Endpoint,
|
||||
P256DH: rec.P256DH,
|
||||
Auth: rec.Auth,
|
||||
})
|
||||
}
|
||||
}
|
||||
return subs, nil
|
||||
}
|
||||
|
||||
// NotifyUsersWithBook creates an in-app notification for every logged-in user
|
||||
// who has slug in their library. Errors for individual users are logged but
|
||||
// do not abort the loop. Returns the number of notifications created.
|
||||
func (s *Store) NotifyUsersWithBook(ctx context.Context, slug, title, message, link string) int {
|
||||
userIDs, err := s.ListUserIDsWithBook(ctx, slug)
|
||||
if err != nil || len(userIDs) == 0 {
|
||||
return 0
|
||||
}
|
||||
var n int
|
||||
for _, uid := range userIDs {
|
||||
if createErr := s.CreateNotification(ctx, uid, title, message, link); createErr == nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
// who have slug in their user_library. Used to fan-out new-chapter notifications.
|
||||
// Admin users and users who have opted out of in-app new-chapter notifications
|
||||
// (notify_new_chapters=false on app_users) are excluded.
|
||||
func (s *Store) ListUserIDsWithBook(ctx context.Context, slug string) ([]string, error) {
|
||||
// Collect user IDs to skip: admins + opted-out users.
|
||||
skipIDs := make(map[string]bool)
|
||||
excludedItems, err := s.pb.listAll(ctx, "app_users", `role="admin"||notify_new_chapters=false`, "")
|
||||
if err == nil {
|
||||
for _, raw := range excludedItems {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
|
||||
skipIDs[rec.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filter := fmt.Sprintf("slug=%q&&user_id!=''", slug)
|
||||
items, err := s.pb.listAll(ctx, "user_library", filter, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ListUserIDsWithBook: %w", err)
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var ids []string
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.UserID != "" && !seen[rec.UserID] && !skipIDs[rec.UserID] {
|
||||
seen[rec.UserID] = true
|
||||
ids = append(ids, rec.UserID)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ type Producer interface {
|
||||
|
||||
// CreateImportTask inserts a new import task with status=pending and
|
||||
// returns the assigned PocketBase record ID.
|
||||
// initiatorUserID is the PocketBase user ID who submitted the import (may be empty).
|
||||
CreateImportTask(ctx context.Context, slug, title, fileType, objectKey, initiatorUserID string) (string, error)
|
||||
// The task struct must have at minimum Slug, Title, FileType, and ObjectKey set.
|
||||
CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error)
|
||||
|
||||
// CancelTask transitions a pending task to status=cancelled.
|
||||
// Returns ErrNotFound if the task does not exist.
|
||||
|
||||
@@ -26,7 +26,7 @@ func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string
|
||||
func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) {
|
||||
return "translation-1", nil
|
||||
}
|
||||
func (s *stubStore) CreateImportTask(_ context.Context, _, _, _, _, _ string) (string, error) {
|
||||
func (s *stubStore) CreateImportTask(_ context.Context, _ domain.ImportTask) (string, error) {
|
||||
return "import-1", nil
|
||||
}
|
||||
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
|
||||
|
||||
147
backend/internal/webpush/webpush.go
Normal file
147
backend/internal/webpush/webpush.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Package webpush sends Web Push notifications using the VAPID protocol.
|
||||
package webpush
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
webpushgo "github.com/SherClockHolmes/webpush-go"
|
||||
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
// Payload is the JSON body delivered to the browser service worker.
|
||||
type Payload struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
// Sender sends Web Push notifications to subscribed browsers.
|
||||
type Sender struct {
|
||||
vapidPublic string
|
||||
vapidPrivate string
|
||||
subject string
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New returns a Sender configured with the given VAPID key pair.
|
||||
// subject should be a mailto: or https: contact URL per the VAPID spec.
|
||||
func New(vapidPublic, vapidPrivate, subject string, log *slog.Logger) *Sender {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Sender{
|
||||
vapidPublic: vapidPublic,
|
||||
vapidPrivate: vapidPrivate,
|
||||
subject: subject,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled returns true when VAPID keys are configured.
|
||||
func (s *Sender) Enabled() bool {
|
||||
return s.vapidPublic != "" && s.vapidPrivate != ""
|
||||
}
|
||||
|
||||
// Send delivers payload to all provided subscriptions concurrently.
|
||||
// Errors for individual subscriptions are logged but do not abort other sends.
|
||||
// Returns the number of successful sends.
|
||||
func (s *Sender) Send(ctx context.Context, subs []storage.PushSubscription, p Payload) int {
|
||||
if !s.Enabled() || len(subs) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
s.log.Error("webpush: marshal payload", "err", err)
|
||||
return 0
|
||||
}
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
success int
|
||||
)
|
||||
|
||||
for _, sub := range subs {
|
||||
sub := sub
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
resp, err := webpushgo.SendNotificationWithContext(ctx, body, &webpushgo.Subscription{
|
||||
Endpoint: sub.Endpoint,
|
||||
Keys: webpushgo.Keys{
|
||||
P256dh: sub.P256DH,
|
||||
Auth: sub.Auth,
|
||||
},
|
||||
}, &webpushgo.Options{
|
||||
VAPIDPublicKey: s.vapidPublic,
|
||||
VAPIDPrivateKey: s.vapidPrivate,
|
||||
Subscriber: s.subject,
|
||||
TTL: 86400,
|
||||
})
|
||||
if err != nil {
|
||||
s.log.Warn("webpush: send failed", "endpoint", truncate(sub.Endpoint, 60), "err", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
if resp.StatusCode >= 400 {
|
||||
s.log.Warn("webpush: push service returned error",
|
||||
"endpoint", truncate(sub.Endpoint, 60),
|
||||
"status", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
success++
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return success
|
||||
}
|
||||
|
||||
// SendToBook sends a push notification to all subscribers of the given book.
|
||||
// store is used to list subscriptions for the book's library followers.
|
||||
func (s *Sender) SendToBook(ctx context.Context, store *storage.Store, slug string, p Payload) {
|
||||
if !s.Enabled() {
|
||||
return
|
||||
}
|
||||
subs, err := store.ListPushSubscriptionsByBook(ctx, slug)
|
||||
if err != nil {
|
||||
s.log.Warn("webpush: list push subscriptions", "slug", slug, "err", err)
|
||||
return
|
||||
}
|
||||
if len(subs) == 0 {
|
||||
return
|
||||
}
|
||||
n := s.Send(ctx, subs, p)
|
||||
s.log.Info("webpush: sent chapter notification",
|
||||
"slug", slug,
|
||||
"recipients", n,
|
||||
"total_subs", len(subs),
|
||||
)
|
||||
}
|
||||
|
||||
// GenerateVAPIDKeys generates a new VAPID key pair and prints them.
|
||||
// Useful for one-off key generation during setup.
|
||||
func GenerateVAPIDKeys() (public, private string, err error) {
|
||||
private, public, err = webpushgo.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("generate VAPID keys: %w", err)
|
||||
}
|
||||
return public, private, nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
431
backend/migrations/20260414000001_initial_schema.go
Normal file
431
backend/migrations/20260414000001_initial_schema.go
Normal file
@@ -0,0 +1,431 @@
|
||||
// Migration 1 — full schema baseline.
|
||||
//
|
||||
// Creates all 21 collections that were previously bootstrapped by
|
||||
// scripts/pb-init-v3.sh. Also creates the initial superuser from the
|
||||
// POCKETBASE_ADMIN_EMAIL / POCKETBASE_ADMIN_PASSWORD env vars (first run only).
|
||||
//
|
||||
// This migration is intentionally idempotent: each collection is skipped if it
|
||||
// already exists. This makes it safe to apply on an existing install without
|
||||
// running `migrate history-sync` first — existing collections are left untouched
|
||||
// and migration 2 still runs to add the three fields that were missing.
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
steps := []func(core.App) error{
|
||||
createBooks,
|
||||
createChaptersIdx,
|
||||
createRanking,
|
||||
createProgress,
|
||||
createScrapingTasks,
|
||||
createAudioJobs,
|
||||
createAppUsers,
|
||||
createUserSessions,
|
||||
createUserLibrary,
|
||||
createUserSettings,
|
||||
createUserSubscriptions,
|
||||
createBookComments,
|
||||
createCommentVotes,
|
||||
createTranslationJobs,
|
||||
createImportTasks,
|
||||
createNotifications,
|
||||
createPushSubscriptions,
|
||||
createAIJobs,
|
||||
createDiscoveryVotes,
|
||||
createBookRatings,
|
||||
createSiteConfig,
|
||||
createInitialSuperuser,
|
||||
}
|
||||
for _, step := range steps {
|
||||
if err := step(app); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, func(app core.App) error {
|
||||
// Down: drop all collections in safe reverse order.
|
||||
names := []string{
|
||||
"site_config", "book_ratings", "discovery_votes", "ai_jobs",
|
||||
"push_subscriptions", "notifications", "import_tasks",
|
||||
"translation_jobs", "comment_votes", "book_comments",
|
||||
"user_subscriptions", "user_settings", "user_library",
|
||||
"user_sessions", "app_users", "audio_jobs", "scraping_tasks",
|
||||
"progress", "ranking", "chapters_idx", "books",
|
||||
}
|
||||
for _, name := range names {
|
||||
coll, err := app.FindCollectionByNameOrId(name)
|
||||
if err != nil {
|
||||
continue // already absent — safe to skip
|
||||
}
|
||||
if err := app.Delete(coll); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// saveIfAbsent saves the collection only when no collection with that name
|
||||
// exists yet. This makes the migration safe to run on an existing install
|
||||
// without history-sync — already-created collections are simply skipped.
|
||||
func saveIfAbsent(app core.App, c *core.Collection) error {
|
||||
if _, err := app.FindCollectionByNameOrId(c.Name); err == nil {
|
||||
return nil // already exists — skip
|
||||
}
|
||||
return app.Save(c)
|
||||
}
|
||||
|
||||
// ── Collection creators ───────────────────────────────────────────────────────
|
||||
|
||||
func createBooks(app core.App) error {
|
||||
c := core.NewBaseCollection("books")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.TextField{Name: "title", Required: true},
|
||||
&core.TextField{Name: "author"},
|
||||
&core.TextField{Name: "cover"},
|
||||
&core.TextField{Name: "status"},
|
||||
&core.JSONField{Name: "genres"},
|
||||
&core.TextField{Name: "summary"},
|
||||
&core.NumberField{Name: "total_chapters"},
|
||||
&core.TextField{Name: "source_url"},
|
||||
&core.NumberField{Name: "ranking"},
|
||||
&core.TextField{Name: "meta_updated"},
|
||||
&core.BoolField{Name: "archived"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createChaptersIdx(app core.App) error {
|
||||
c := core.NewBaseCollection("chapters_idx")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.NumberField{Name: "number", Required: true},
|
||||
&core.TextField{Name: "title"},
|
||||
)
|
||||
// Enforce uniqueness on (slug, number) — prevents duplicate chapter entries.
|
||||
c.AddIndex("idx_chapters_idx_slug_number", true, "slug, number", "")
|
||||
// Allow fast "recently updated books" queries.
|
||||
c.AddIndex("idx_chapters_idx_created", false, "created", "")
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createRanking(app core.App) error {
|
||||
c := core.NewBaseCollection("ranking")
|
||||
c.Fields.Add(
|
||||
&core.NumberField{Name: "rank", Required: true},
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.TextField{Name: "title"},
|
||||
&core.TextField{Name: "author"},
|
||||
&core.TextField{Name: "cover"},
|
||||
&core.TextField{Name: "status"},
|
||||
&core.JSONField{Name: "genres"},
|
||||
&core.TextField{Name: "source_url"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createProgress(app core.App) error {
|
||||
c := core.NewBaseCollection("progress")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "session_id", Required: true},
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.NumberField{Name: "chapter"},
|
||||
&core.TextField{Name: "user_id"},
|
||||
&core.NumberField{Name: "audio_time"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createScrapingTasks(app core.App) error {
|
||||
c := core.NewBaseCollection("scraping_tasks")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "kind"},
|
||||
&core.TextField{Name: "target_url"},
|
||||
&core.NumberField{Name: "from_chapter"},
|
||||
&core.NumberField{Name: "to_chapter"},
|
||||
&core.TextField{Name: "worker_id"},
|
||||
&core.TextField{Name: "status", Required: true},
|
||||
&core.NumberField{Name: "books_found"},
|
||||
&core.NumberField{Name: "chapters_scraped"},
|
||||
&core.NumberField{Name: "chapters_skipped"},
|
||||
&core.NumberField{Name: "errors"},
|
||||
&core.TextField{Name: "error_message"},
|
||||
&core.DateField{Name: "started"},
|
||||
&core.DateField{Name: "finished"},
|
||||
&core.DateField{Name: "heartbeat_at"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createAudioJobs(app core.App) error {
|
||||
c := core.NewBaseCollection("audio_jobs")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "cache_key", Required: true},
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.NumberField{Name: "chapter", Required: true},
|
||||
&core.TextField{Name: "voice"},
|
||||
&core.TextField{Name: "worker_id"},
|
||||
&core.TextField{Name: "status", Required: true},
|
||||
&core.TextField{Name: "error_message"},
|
||||
&core.DateField{Name: "started"},
|
||||
&core.DateField{Name: "finished"},
|
||||
&core.DateField{Name: "heartbeat_at"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createAppUsers(app core.App) error {
|
||||
c := core.NewBaseCollection("app_users")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "username", Required: true},
|
||||
&core.TextField{Name: "password_hash"},
|
||||
&core.TextField{Name: "role"},
|
||||
&core.TextField{Name: "avatar_url"},
|
||||
&core.TextField{Name: "email"},
|
||||
&core.BoolField{Name: "email_verified"},
|
||||
&core.TextField{Name: "verification_token"},
|
||||
&core.TextField{Name: "verification_token_exp"},
|
||||
&core.TextField{Name: "oauth_provider"},
|
||||
&core.TextField{Name: "oauth_id"},
|
||||
&core.TextField{Name: "polar_customer_id"},
|
||||
&core.TextField{Name: "polar_subscription_id"},
|
||||
&core.BoolField{Name: "notify_new_chapters"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createUserSessions(app core.App) error {
|
||||
c := core.NewBaseCollection("user_sessions")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "user_id", Required: true},
|
||||
&core.TextField{Name: "session_id", Required: true},
|
||||
&core.TextField{Name: "user_agent"},
|
||||
&core.TextField{Name: "ip"},
|
||||
&core.TextField{Name: "device_fingerprint"},
|
||||
// created_at is a custom text field (not the system `created` date field).
|
||||
&core.TextField{Name: "created_at"},
|
||||
&core.TextField{Name: "last_seen"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createUserLibrary(app core.App) error {
|
||||
c := core.NewBaseCollection("user_library")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "session_id", Required: true},
|
||||
&core.TextField{Name: "user_id"},
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.TextField{Name: "saved_at"},
|
||||
&core.TextField{Name: "shelf"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createUserSettings(app core.App) error {
|
||||
c := core.NewBaseCollection("user_settings")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "session_id", Required: true},
|
||||
&core.TextField{Name: "user_id"},
|
||||
&core.BoolField{Name: "auto_next"},
|
||||
&core.TextField{Name: "voice"},
|
||||
&core.NumberField{Name: "speed"},
|
||||
&core.TextField{Name: "theme"},
|
||||
&core.TextField{Name: "locale"},
|
||||
&core.TextField{Name: "font_family"},
|
||||
&core.NumberField{Name: "font_size"},
|
||||
&core.BoolField{Name: "announce_chapter"},
|
||||
&core.TextField{Name: "audio_mode"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createUserSubscriptions(app core.App) error {
|
||||
c := core.NewBaseCollection("user_subscriptions")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "follower_id", Required: true},
|
||||
&core.TextField{Name: "followee_id", Required: true},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createBookComments(app core.App) error {
|
||||
c := core.NewBaseCollection("book_comments")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.TextField{Name: "user_id"},
|
||||
&core.TextField{Name: "username"},
|
||||
&core.TextField{Name: "body"},
|
||||
&core.NumberField{Name: "upvotes"},
|
||||
&core.NumberField{Name: "downvotes"},
|
||||
&core.TextField{Name: "parent_id"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createCommentVotes(app core.App) error {
|
||||
c := core.NewBaseCollection("comment_votes")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "comment_id", Required: true},
|
||||
&core.TextField{Name: "user_id"},
|
||||
&core.TextField{Name: "session_id"},
|
||||
&core.TextField{Name: "vote"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createTranslationJobs(app core.App) error {
|
||||
c := core.NewBaseCollection("translation_jobs")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "cache_key", Required: true},
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.NumberField{Name: "chapter", Required: true},
|
||||
&core.TextField{Name: "lang", Required: true},
|
||||
&core.TextField{Name: "worker_id"},
|
||||
&core.TextField{Name: "status", Required: true},
|
||||
&core.TextField{Name: "error_message"},
|
||||
&core.DateField{Name: "started"},
|
||||
&core.DateField{Name: "finished"},
|
||||
&core.DateField{Name: "heartbeat_at"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createImportTasks(app core.App) error {
|
||||
c := core.NewBaseCollection("import_tasks")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.TextField{Name: "title", Required: true},
|
||||
&core.TextField{Name: "file_name"},
|
||||
&core.TextField{Name: "file_type"},
|
||||
&core.TextField{Name: "object_key"},
|
||||
&core.TextField{Name: "chapters_key"},
|
||||
&core.TextField{Name: "author"},
|
||||
&core.TextField{Name: "cover_url"},
|
||||
&core.TextField{Name: "genres"},
|
||||
&core.TextField{Name: "summary"},
|
||||
&core.TextField{Name: "book_status"},
|
||||
&core.TextField{Name: "worker_id"},
|
||||
&core.TextField{Name: "initiator_user_id"},
|
||||
&core.TextField{Name: "status", Required: true},
|
||||
&core.NumberField{Name: "chapters_done"},
|
||||
&core.NumberField{Name: "chapters_total"},
|
||||
&core.TextField{Name: "error_message"},
|
||||
&core.DateField{Name: "started"},
|
||||
&core.DateField{Name: "finished"},
|
||||
&core.DateField{Name: "heartbeat_at"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createNotifications(app core.App) error {
|
||||
c := core.NewBaseCollection("notifications")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "user_id", Required: true},
|
||||
&core.TextField{Name: "title", Required: true},
|
||||
&core.TextField{Name: "message"},
|
||||
&core.TextField{Name: "link"},
|
||||
&core.BoolField{Name: "read"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createPushSubscriptions(app core.App) error {
|
||||
c := core.NewBaseCollection("push_subscriptions")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "user_id", Required: true},
|
||||
&core.TextField{Name: "endpoint", Required: true},
|
||||
&core.TextField{Name: "p256dh", Required: true},
|
||||
&core.TextField{Name: "auth", Required: true},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createAIJobs(app core.App) error {
|
||||
c := core.NewBaseCollection("ai_jobs")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "kind", Required: true},
|
||||
&core.TextField{Name: "slug"},
|
||||
&core.TextField{Name: "status", Required: true},
|
||||
&core.NumberField{Name: "from_item"},
|
||||
&core.NumberField{Name: "to_item"},
|
||||
&core.NumberField{Name: "items_done"},
|
||||
&core.NumberField{Name: "items_total"},
|
||||
&core.TextField{Name: "model"},
|
||||
&core.TextField{Name: "payload"},
|
||||
&core.TextField{Name: "error_message"},
|
||||
&core.DateField{Name: "started"},
|
||||
&core.DateField{Name: "finished"},
|
||||
&core.DateField{Name: "heartbeat_at"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createDiscoveryVotes(app core.App) error {
|
||||
c := core.NewBaseCollection("discovery_votes")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "session_id", Required: true},
|
||||
&core.TextField{Name: "user_id"},
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.TextField{Name: "action", Required: true},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createBookRatings(app core.App) error {
|
||||
c := core.NewBaseCollection("book_ratings")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "session_id", Required: true},
|
||||
&core.TextField{Name: "user_id"},
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.NumberField{Name: "rating", Required: true},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createSiteConfig(app core.App) error {
|
||||
c := core.NewBaseCollection("site_config")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "decoration"},
|
||||
&core.TextField{Name: "logoAnimation"},
|
||||
&core.TextField{Name: "eventLabel"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
// createInitialSuperuser creates the first PocketBase superuser from env vars.
|
||||
// It is a no-op if a superuser with that email already exists, or if the env
|
||||
// vars are not set. This replaces the superuser bootstrap block in
|
||||
// scripts/pb-init-v3.sh.
|
||||
func createInitialSuperuser(app core.App) error {
|
||||
email := os.Getenv("POCKETBASE_ADMIN_EMAIL")
|
||||
password := os.Getenv("POCKETBASE_ADMIN_PASSWORD")
|
||||
if email == "" || password == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
existing, _ := app.FindFirstRecordByData("_superusers", "email", email)
|
||||
if existing != nil {
|
||||
return nil // superuser already exists
|
||||
}
|
||||
|
||||
superusers, err := app.FindCollectionByNameOrId("_superusers")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := core.NewRecord(superusers)
|
||||
record.Set("email", email)
|
||||
record.Set("password", password)
|
||||
record.Set("passwordConfirm", password)
|
||||
return app.Save(record)
|
||||
}
|
||||
71
backend/migrations/20260414000002_missing_fields.go
Normal file
71
backend/migrations/20260414000002_missing_fields.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Migration 2 — add fields present in code but absent from pb-init-v3.sh.
|
||||
//
|
||||
// Discovered by auditing every PocketBase field access in the Go backend
|
||||
// and SvelteKit UI against the collection definitions in pb-init-v3.sh:
|
||||
//
|
||||
// books.rating (number) — written by WriteMetadata but never defined.
|
||||
// app_users.notify_new_chapters_push (bool) — used in UI push-notification opt-in.
|
||||
// book_comments.chapter (number) — used to scope comments to a chapter (0 = book-level).
|
||||
//
|
||||
// The check for field existence makes this migration safe to re-apply on
|
||||
// a fresh install where migration 1 already created the collections without
|
||||
// these fields.
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
type addition struct {
|
||||
collection string
|
||||
field core.Field
|
||||
}
|
||||
additions := []addition{
|
||||
{"books", &core.NumberField{Name: "rating"}},
|
||||
{"app_users", &core.BoolField{Name: "notify_new_chapters_push"}},
|
||||
{"book_comments", &core.NumberField{Name: "chapter"}},
|
||||
}
|
||||
for _, a := range additions {
|
||||
coll, err := app.FindCollectionByNameOrId(a.collection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if coll.Fields.GetByName(a.field.GetName()) != nil {
|
||||
continue // already present — idempotent
|
||||
}
|
||||
coll.Fields.Add(a.field)
|
||||
if err := app.Save(coll); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, func(app core.App) error {
|
||||
type removal struct {
|
||||
collection string
|
||||
field string
|
||||
}
|
||||
removals := []removal{
|
||||
{"books", "rating"},
|
||||
{"app_users", "notify_new_chapters_push"},
|
||||
{"book_comments", "chapter"},
|
||||
}
|
||||
for _, r := range removals {
|
||||
coll, err := app.FindCollectionByNameOrId(r.collection)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
f := coll.Fields.GetByName(r.field)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
coll.Fields.RemoveById(f.GetId())
|
||||
if err := app.Save(coll); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
75
backend/migrations/20260414000003_visibility.go
Normal file
75
backend/migrations/20260414000003_visibility.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Migration 3 — add visibility + submitted_by fields to books.
|
||||
//
|
||||
// visibility: "public" | "admin_only"
|
||||
// All existing (scraped) books are backfilled to "admin_only".
|
||||
// New author-submitted books are created with "public".
|
||||
//
|
||||
// submitted_by: optional app_users ID for books submitted by a registered author.
|
||||
// Empty for scraped books.
|
||||
//
|
||||
// The backfill iterates books in pages of 200. It is idempotent: books whose
|
||||
// visibility is already set are skipped.
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
coll, err := app.FindCollectionByNameOrId("books")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
changed := false
|
||||
if coll.Fields.GetByName("visibility") == nil {
|
||||
coll.Fields.Add(&core.TextField{Name: "visibility"})
|
||||
changed = true
|
||||
}
|
||||
if coll.Fields.GetByName("submitted_by") == nil {
|
||||
coll.Fields.Add(&core.TextField{Name: "submitted_by"})
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if err := app.Save(coll); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Backfill: mark all existing books as admin_only where visibility is empty.
|
||||
// These are scraped books that pre-date this migration.
|
||||
const perPage = 200
|
||||
for page := 1; ; page++ {
|
||||
records, err := app.FindRecordsByFilter(
|
||||
"books", `visibility=""`, "+id", perPage, (page-1)*perPage, nil,
|
||||
)
|
||||
if err != nil || len(records) == 0 {
|
||||
break
|
||||
}
|
||||
for _, rec := range records {
|
||||
rec.Set("visibility", "admin_only")
|
||||
// Best-effort: ignore individual save errors (don't abort migration).
|
||||
_ = app.Save(rec)
|
||||
}
|
||||
if len(records) < perPage {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, func(app core.App) error {
|
||||
coll, err := app.FindCollectionByNameOrId("books")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, name := range []string{"visibility", "submitted_by"} {
|
||||
f := coll.Fields.GetByName(name)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
coll.Fields.RemoveById(f.GetId())
|
||||
}
|
||||
return app.Save(coll)
|
||||
})
|
||||
}
|
||||
106
docker-bake.hcl
Normal file
106
docker-bake.hcl
Normal file
@@ -0,0 +1,106 @@
|
||||
# docker-bake.hcl — defines all five production images.
|
||||
#
|
||||
# CI passes version info as environment variables; locally everything gets :dev tags.
|
||||
#
|
||||
# Local build (no push):
|
||||
# docker buildx bake
|
||||
#
|
||||
# CI environment variables: VERSION, MAJOR_MINOR, COMMIT, BUILD_TIME
|
||||
|
||||
variable "DOCKER_USER" { default = "kalekber" }
|
||||
variable "VERSION" { default = "dev" } # e.g. "4.1.6" (no leading v)
|
||||
variable "MAJOR_MINOR" { default = "dev" } # e.g. "4.1"
|
||||
variable "COMMIT" { default = "unknown" }
|
||||
variable "BUILD_TIME" { default = "" }
|
||||
|
||||
# ── Shared defaults ───────────────────────────────────────────────────────────
|
||||
|
||||
target "_defaults" {
|
||||
pull = true
|
||||
# CI overrides to push=true via --set *.output=type=image,push=true
|
||||
output = ["type=image,push=false"]
|
||||
cache-to = ["type=inline"]
|
||||
}
|
||||
|
||||
# ── Go targets (share the backend/ build context + builder stage) ─────────────
|
||||
|
||||
target "backend" {
|
||||
inherits = ["_defaults"]
|
||||
context = "backend"
|
||||
target = "backend"
|
||||
tags = [
|
||||
"${DOCKER_USER}/libnovel-backend:${VERSION}",
|
||||
"${DOCKER_USER}/libnovel-backend:${MAJOR_MINOR}",
|
||||
"${DOCKER_USER}/libnovel-backend:latest",
|
||||
]
|
||||
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-backend:latest"]
|
||||
args = {
|
||||
VERSION = VERSION
|
||||
COMMIT = COMMIT
|
||||
}
|
||||
}
|
||||
|
||||
target "runner" {
|
||||
inherits = ["_defaults"]
|
||||
context = "backend"
|
||||
target = "runner"
|
||||
tags = [
|
||||
"${DOCKER_USER}/libnovel-runner:${VERSION}",
|
||||
"${DOCKER_USER}/libnovel-runner:${MAJOR_MINOR}",
|
||||
"${DOCKER_USER}/libnovel-runner:latest",
|
||||
]
|
||||
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-runner:latest"]
|
||||
args = {
|
||||
VERSION = VERSION
|
||||
COMMIT = COMMIT
|
||||
}
|
||||
}
|
||||
|
||||
target "pocketbase" {
|
||||
inherits = ["_defaults"]
|
||||
context = "backend"
|
||||
target = "pocketbase"
|
||||
tags = [
|
||||
"${DOCKER_USER}/libnovel-pocketbase:${VERSION}",
|
||||
"${DOCKER_USER}/libnovel-pocketbase:${MAJOR_MINOR}",
|
||||
"${DOCKER_USER}/libnovel-pocketbase:latest",
|
||||
]
|
||||
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-pocketbase:latest"]
|
||||
}
|
||||
|
||||
# ── UI (SvelteKit — separate context) ────────────────────────────────────────
|
||||
|
||||
target "ui" {
|
||||
inherits = ["_defaults"]
|
||||
context = "ui"
|
||||
tags = [
|
||||
"${DOCKER_USER}/libnovel-ui:${VERSION}",
|
||||
"${DOCKER_USER}/libnovel-ui:${MAJOR_MINOR}",
|
||||
"${DOCKER_USER}/libnovel-ui:latest",
|
||||
]
|
||||
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-ui:latest"]
|
||||
args = {
|
||||
BUILD_VERSION = VERSION
|
||||
BUILD_COMMIT = COMMIT
|
||||
BUILD_TIME = BUILD_TIME
|
||||
}
|
||||
}
|
||||
|
||||
# ── Caddy (custom plugins — separate context) ─────────────────────────────────
|
||||
|
||||
target "caddy" {
|
||||
inherits = ["_defaults"]
|
||||
context = "caddy"
|
||||
tags = [
|
||||
"${DOCKER_USER}/libnovel-caddy:${VERSION}",
|
||||
"${DOCKER_USER}/libnovel-caddy:${MAJOR_MINOR}",
|
||||
"${DOCKER_USER}/libnovel-caddy:latest",
|
||||
]
|
||||
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-caddy:latest"]
|
||||
}
|
||||
|
||||
# ── Default group: all five images ────────────────────────────────────────────
|
||||
|
||||
group "default" {
|
||||
targets = ["backend", "runner", "pocketbase", "ui", "caddy"]
|
||||
}
|
||||
@@ -58,6 +58,8 @@ services:
|
||||
mc mb --ignore-existing local/audio;
|
||||
mc mb --ignore-existing local/avatars;
|
||||
mc mb --ignore-existing local/catalogue;
|
||||
mc mb --ignore-existing local/translations;
|
||||
mc mb --ignore-existing local/imports;
|
||||
echo 'buckets ready';
|
||||
"
|
||||
environment:
|
||||
@@ -65,12 +67,21 @@ services:
|
||||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD}"
|
||||
|
||||
# ─── PocketBase (auth + structured data) ─────────────────────────────────────
|
||||
# Custom binary built from backend/cmd/pocketbase — runs Go migrations on every
|
||||
# startup before accepting traffic, replacing the old pb-init-v3.sh script.
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
image: kalekber/libnovel-pocketbase:${GIT_TAG:-latest}
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: pocketbase
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PB_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||
PB_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||
# Used by migration 1 to create the initial superuser on a fresh install.
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||
# No public port — accessed only by backend/runner on the internal network.
|
||||
expose:
|
||||
- "8090"
|
||||
@@ -80,25 +91,12 @@ services:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── PocketBase collection bootstrap ─────────────────────────────────────────
|
||||
pb-init:
|
||||
image: alpine:3.19
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POCKETBASE_URL: "http://pocketbase:8090"
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||
volumes:
|
||||
- ./scripts/pb-init-v3.sh:/pb-init.sh:ro
|
||||
entrypoint: ["sh", "/pb-init.sh"]
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
# ─── Meilisearch (full-text search) ──────────────────────────────────────────
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:latest
|
||||
image: getmeili/meilisearch:v1.40.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MEILI_MASTER_KEY: "${MEILI_MASTER_KEY}"
|
||||
@@ -164,8 +162,6 @@ services:
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 35s
|
||||
depends_on:
|
||||
pb-init:
|
||||
condition: service_completed_successfully
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
@@ -182,6 +178,7 @@ services:
|
||||
environment:
|
||||
<<: *infra-env
|
||||
BACKEND_HTTP_ADDR: ":8080"
|
||||
BACKEND_ADMIN_TOKEN: "${BACKEND_ADMIN_TOKEN}"
|
||||
LOG_LEVEL: "${LOG_LEVEL}"
|
||||
KOKORO_URL: "${KOKORO_URL}"
|
||||
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||
@@ -218,8 +215,6 @@ services:
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 135s
|
||||
depends_on:
|
||||
pb-init:
|
||||
condition: service_completed_successfully
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
@@ -273,8 +268,6 @@ services:
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 35s
|
||||
depends_on:
|
||||
pb-init:
|
||||
condition: service_completed_successfully
|
||||
backend:
|
||||
condition: service_healthy
|
||||
pocketbase:
|
||||
@@ -293,6 +286,7 @@ services:
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||
AUTH_SECRET: "${AUTH_SECRET}"
|
||||
BACKEND_ADMIN_TOKEN: "${BACKEND_ADMIN_TOKEN}"
|
||||
DEBUG_LOGIN_TOKEN: "${DEBUG_LOGIN_TOKEN}"
|
||||
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT}"
|
||||
# Valkey
|
||||
@@ -307,6 +301,8 @@ services:
|
||||
# OpenTelemetry tracing
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
|
||||
OTEL_SERVICE_NAME: "ui"
|
||||
# Allow large PDF/EPUB uploads (adapter-node default is 512KB)
|
||||
BODY_SIZE_LIMIT: "52428800"
|
||||
# OAuth2 providers
|
||||
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}"
|
||||
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
# LibNovel homelab runner
|
||||
#
|
||||
# Connects to production PocketBase and MinIO via public subdomains.
|
||||
# All secrets come from Doppler (project=libnovel, config=prd_homelab).
|
||||
# Run with: doppler run -- docker compose up -d
|
||||
#
|
||||
# Differs from prod runner:
|
||||
# - RUNNER_WORKER_ID=homelab-runner-1 (unique, avoids task claiming conflicts)
|
||||
# - MINIO_ENDPOINT/USE_SSL → storage.libnovel.cc over HTTPS
|
||||
# - POCKETBASE_URL → https://pb.libnovel.cc
|
||||
# - MEILI_URL → https://search.libnovel.cc (Caddy-proxied)
|
||||
# - VALKEY_ADDR → unset (not exposed publicly)
|
||||
# - RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true
|
||||
# - REDIS_ADDR → rediss://redis.libnovel.cc:6380 (prod Redis via Caddy TLS proxy)
|
||||
# - LibreTranslate service for machine translation (internal network only)
|
||||
#
|
||||
# extra_hosts pins storage.libnovel.cc and pb.libnovel.cc to the prod server IP
|
||||
# (165.22.70.138) so that large PutObject uploads and PocketBase writes bypass
|
||||
# Cloudflare's 100-second proxy timeout entirely. TLS still terminates at Caddy
|
||||
# on prod; the TLS certificate is valid for the domain names so SNI works fine.
|
||||
|
||||
services:
|
||||
libretranslate:
|
||||
image: libretranslate/libretranslate:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LT_API_KEYS: "true"
|
||||
LT_API_KEYS_DB_PATH: "/app/db/api_keys.db"
|
||||
# Limit to source→target pairs the runner actually uses
|
||||
LT_LOAD_ONLY: "en,ru,id,pt,fr"
|
||||
LT_DISABLE_WEB_UI: "true"
|
||||
LT_UPDATE_MODELS: "false"
|
||||
volumes:
|
||||
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
|
||||
- libretranslate_db:/app/db
|
||||
|
||||
runner:
|
||||
image: kalekber/libnovel-runner:latest
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 135s
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
depends_on:
|
||||
- libretranslate
|
||||
# Pin prod subdomains to the prod server IP to bypass Cloudflare's 100s
|
||||
# proxy timeout. Large MP3 PutObject uploads and PocketBase writes go
|
||||
# directly to Caddy on prod; TLS and SNI still work normally.
|
||||
extra_hosts:
|
||||
- "storage.libnovel.cc:165.22.70.138"
|
||||
- "pb.libnovel.cc:165.22.70.138"
|
||||
environment:
|
||||
# ── PocketBase ──────────────────────────────────────────────────────────
|
||||
POCKETBASE_URL: "https://pb.libnovel.cc"
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||
|
||||
# ── MinIO (S3 API via public subdomain) ─────────────────────────────────
|
||||
MINIO_ENDPOINT: "storage.libnovel.cc"
|
||||
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER}"
|
||||
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD}"
|
||||
MINIO_USE_SSL: "true"
|
||||
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT}"
|
||||
MINIO_PUBLIC_USE_SSL: "${MINIO_PUBLIC_USE_SSL}"
|
||||
|
||||
# ── Meilisearch (via search.libnovel.cc Caddy proxy) ────────────────────
|
||||
MEILI_URL: "${MEILI_URL}"
|
||||
MEILI_API_KEY: "${MEILI_API_KEY}"
|
||||
VALKEY_ADDR: ""
|
||||
# Force IPv4 DNS resolution — homelab has no IPv6 route to search.libnovel.cc
|
||||
GODEBUG: "preferIPv4=1"
|
||||
|
||||
# ── Kokoro TTS ──────────────────────────────────────────────────────────
|
||||
KOKORO_URL: "${KOKORO_URL}"
|
||||
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||
|
||||
# ── Pocket TTS ──────────────────────────────────────────────────────────
|
||||
POCKET_TTS_URL: "${POCKET_TTS_URL}"
|
||||
|
||||
# ── Cloudflare Workers AI TTS ────────────────────────────────────────────
|
||||
CFAI_ACCOUNT_ID: "${CFAI_ACCOUNT_ID}"
|
||||
CFAI_API_TOKEN: "${CFAI_API_TOKEN}"
|
||||
|
||||
# ── LibreTranslate (internal Docker network) ────────────────────────────
|
||||
LIBRETRANSLATE_URL: "http://libretranslate:5000"
|
||||
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
|
||||
|
||||
# ── Asynq / Redis (prod Redis via Caddy TLS proxy) ──────────────────────
|
||||
# The runner connects to prod Redis over TLS: rediss://redis.libnovel.cc:6380.
|
||||
# Caddy on prod terminates TLS and proxies to the local redis:6379 sidecar.
|
||||
REDIS_ADDR: "${REDIS_ADDR}"
|
||||
REDIS_PASSWORD: "${REDIS_PASSWORD}"
|
||||
|
||||
# ── Runner tuning ───────────────────────────────────────────────────────
|
||||
RUNNER_WORKER_ID: "${RUNNER_WORKER_ID}"
|
||||
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}"
|
||||
RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}"
|
||||
RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}"
|
||||
RUNNER_MAX_CONCURRENT_TRANSLATION: "${RUNNER_MAX_CONCURRENT_TRANSLATION}"
|
||||
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}"
|
||||
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}"
|
||||
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
|
||||
|
||||
# ── Observability ───────────────────────────────────────────────────────
|
||||
LOG_LEVEL: "${LOG_LEVEL}"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
libretranslate_models:
|
||||
libretranslate_db:
|
||||
4
justfile
4
justfile
@@ -56,14 +56,14 @@ build-svc svc:
|
||||
|
||||
# Push all custom images to Docker Hub (requires docker login)
|
||||
push:
|
||||
{{doppler}} docker compose push backend runner ui caddy
|
||||
{{doppler}} docker compose push backend runner ui caddy pocketbase
|
||||
|
||||
# Build then push all custom images
|
||||
build-push: build push
|
||||
|
||||
# Pull all images from Docker Hub (uses GIT_TAG from Doppler)
|
||||
pull-images:
|
||||
{{doppler}} docker compose pull backend runner ui caddy
|
||||
{{doppler}} docker compose pull backend runner ui caddy pocketbase
|
||||
|
||||
# Pull all third-party base images (minio, pocketbase, etc.)
|
||||
pull-infra:
|
||||
|
||||
@@ -144,7 +144,8 @@ create "books" '{
|
||||
{"name":"total_chapters","type":"number"},
|
||||
{"name":"source_url", "type":"text"},
|
||||
{"name":"ranking", "type":"number"},
|
||||
{"name":"meta_updated", "type":"text"}
|
||||
{"name":"meta_updated", "type":"text"},
|
||||
{"name":"archived", "type":"bool"}
|
||||
]}'
|
||||
|
||||
create "chapters_idx" '{
|
||||
@@ -255,6 +256,7 @@ create "user_settings" '{
|
||||
{"name":"font_family", "type":"text"},
|
||||
{"name":"font_size", "type":"number"},
|
||||
{"name":"announce_chapter","type":"bool"},
|
||||
{"name":"audio_mode", "type":"text"},
|
||||
{"name":"updated", "type":"text"}
|
||||
]}'
|
||||
|
||||
@@ -299,6 +301,48 @@ create "translation_jobs" '{
|
||||
{"name":"heartbeat_at", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "import_tasks" '{
|
||||
"name":"import_tasks","type":"base","fields":[
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"title", "type":"text", "required":true},
|
||||
{"name":"file_name", "type":"text"},
|
||||
{"name":"file_type", "type":"text"},
|
||||
{"name":"object_key", "type":"text"},
|
||||
{"name":"chapters_key", "type":"text"},
|
||||
{"name":"author", "type":"text"},
|
||||
{"name":"cover_url", "type":"text"},
|
||||
{"name":"genres", "type":"text"},
|
||||
{"name":"summary", "type":"text"},
|
||||
{"name":"book_status", "type":"text"},
|
||||
{"name":"worker_id", "type":"text"},
|
||||
{"name":"initiator_user_id", "type":"text"},
|
||||
{"name":"status", "type":"text", "required":true},
|
||||
{"name":"chapters_done", "type":"number"},
|
||||
{"name":"chapters_total", "type":"number"},
|
||||
{"name":"error_message", "type":"text"},
|
||||
{"name":"started", "type":"date"},
|
||||
{"name":"finished", "type":"date"},
|
||||
{"name":"heartbeat_at", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "notifications" '{
|
||||
"name":"notifications","type":"base","fields":[
|
||||
{"name":"user_id", "type":"text","required":true},
|
||||
{"name":"title", "type":"text","required":true},
|
||||
{"name":"message", "type":"text"},
|
||||
{"name":"link", "type":"text"},
|
||||
{"name":"read", "type":"bool"},
|
||||
{"name":"created", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "push_subscriptions" '{
|
||||
"name":"push_subscriptions","type":"base","fields":[
|
||||
{"name":"user_id", "type":"text","required":true},
|
||||
{"name":"endpoint", "type":"text","required":true},
|
||||
{"name":"p256dh", "type":"text","required":true},
|
||||
{"name":"auth", "type":"text","required":true}
|
||||
]}'
|
||||
|
||||
create "ai_jobs" '{
|
||||
"name":"ai_jobs","type":"base","fields":[
|
||||
{"name":"kind", "type":"text", "required":true},
|
||||
@@ -332,6 +376,13 @@ create "book_ratings" '{
|
||||
{"name":"rating", "type":"number", "required":true}
|
||||
]}'
|
||||
|
||||
create "site_config" '{
|
||||
"name":"site_config","type":"base","fields":[
|
||||
{"name":"decoration", "type":"text"},
|
||||
{"name":"logoAnimation", "type":"text"},
|
||||
{"name":"eventLabel", "type":"text"}
|
||||
]}'
|
||||
|
||||
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
|
||||
add_field "scraping_tasks" "heartbeat_at" "date"
|
||||
add_field "audio_jobs" "heartbeat_at" "date"
|
||||
@@ -355,6 +406,9 @@ add_field "user_settings" "locale" "text"
|
||||
add_field "user_settings" "font_family" "text"
|
||||
add_field "user_settings" "font_size" "number"
|
||||
add_field "user_settings" "announce_chapter" "bool"
|
||||
add_field "user_settings" "audio_mode" "text"
|
||||
add_field "books" "archived" "bool"
|
||||
add_field "app_users" "notify_new_chapters" "bool"
|
||||
|
||||
# ── 6. Indexes ────────────────────────────────────────────────────────────────
|
||||
add_index "chapters_idx" "idx_chapters_idx_slug_number" \
|
||||
|
||||
@@ -21,11 +21,7 @@ ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
|
||||
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
|
||||
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
|
||||
|
||||
# PREBUILT=1 skips npm run build — used in CI when the build/ directory has
|
||||
# already been compiled (and debug IDs injected) by a prior job. The caller
|
||||
# must copy the pre-built build/ into the Docker context before building.
|
||||
ARG PREBUILT=0
|
||||
RUN [ "$PREBUILT" = "1" ] || npm run build
|
||||
RUN npm run build
|
||||
|
||||
# ── Runtime image ──────────────────────────────────────────────────────────────
|
||||
# adapter-node bundles most server-side code, but packages with dynamic
|
||||
|
||||
@@ -408,6 +408,7 @@
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "AI Jobs",
|
||||
"admin_nav_notifications": "Notifications",
|
||||
"admin_nav_feedback": "Feedback",
|
||||
"admin_nav_errors": "Errors",
|
||||
"admin_nav_analytics": "Analytics",
|
||||
|
||||
@@ -378,6 +378,7 @@
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "Tâches IA",
|
||||
"admin_nav_notifications": "Notifications",
|
||||
"admin_nav_errors": "Erreurs",
|
||||
"admin_nav_analytics": "Analytique",
|
||||
"admin_nav_logs": "Journaux",
|
||||
|
||||
@@ -378,6 +378,7 @@
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "Tugas AI",
|
||||
"admin_nav_notifications": "Notifikasi",
|
||||
"admin_nav_errors": "Kesalahan",
|
||||
"admin_nav_analytics": "Analitik",
|
||||
"admin_nav_logs": "Log",
|
||||
|
||||
@@ -378,6 +378,7 @@
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "Tarefas de IA",
|
||||
"admin_nav_notifications": "Notificações",
|
||||
"admin_nav_errors": "Erros",
|
||||
"admin_nav_analytics": "Análise",
|
||||
"admin_nav_logs": "Logs",
|
||||
|
||||
@@ -378,6 +378,7 @@
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "Задачи ИИ",
|
||||
"admin_nav_notifications": "Уведомления",
|
||||
"admin_nav_errors": "Ошибки",
|
||||
"admin_nav_analytics": "Аналитика",
|
||||
"admin_nav_logs": "Логи",
|
||||
|
||||
3975
ui/package-lock.json
generated
3975
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -29,8 +29,6 @@
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1005.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||
"@grafana/faro-web-sdk": "^2.3.1",
|
||||
"@inlang/paraglide-js": "^2.15.1",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
|
||||
@@ -100,13 +100,13 @@
|
||||
/* ── Forest theme — dark green ────────────────────────────────────────── */
|
||||
[data-theme="forest"] {
|
||||
--color-brand: #4ade80; /* green-400 */
|
||||
--color-brand-dim: #16a34a; /* green-600 */
|
||||
--color-brand-dim: #22c55e; /* green-500 — brighter than green-600 for hover */
|
||||
--color-surface: #0a130d; /* custom near-black green */
|
||||
--color-surface-2: #111c14; /* custom dark green */
|
||||
--color-surface-3: #1a2e1e; /* custom mid green */
|
||||
--color-muted: #6b9a77; /* custom muted green */
|
||||
--color-text: #e8f5e9; /* custom light green-tinted white */
|
||||
--color-border: #1e3a24; /* custom green border */
|
||||
--color-surface-2: #14201a; /* custom dark green — lifted slightly for card depth */
|
||||
--color-surface-3: #1f3326; /* custom mid green — more contrast from surface-2 */
|
||||
--color-muted: #a3c9a8; /* lightened: readable body text, still green-tinted */
|
||||
--color-text: #eaf5eb; /* near-white with green tint */
|
||||
--color-border: #2c4e34; /* more visible green border */
|
||||
--color-danger: #f87171; /* red-400 */
|
||||
--color-success: #4ade80; /* green-400 */
|
||||
}
|
||||
@@ -202,9 +202,10 @@ html {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 2px;
|
||||
height: 3px;
|
||||
z-index: 100;
|
||||
background: var(--color-brand);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--color-brand) 70%, transparent);
|
||||
pointer-events: none;
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
@@ -250,6 +251,52 @@ html {
|
||||
animation: progress-bar 4s cubic-bezier(0.1, 0.05, 0.1, 1) forwards;
|
||||
}
|
||||
|
||||
/* ── Logo animation classes (used in nav + admin preview) ───────────── */
|
||||
@keyframes logo-glow-pulse {
|
||||
0%, 100% { text-shadow: 0 0 6px color-mix(in srgb, var(--color-brand) 60%, transparent); }
|
||||
50% { text-shadow: 0 0 18px color-mix(in srgb, var(--color-brand) 90%, transparent), 0 0 32px color-mix(in srgb, var(--color-brand) 40%, transparent); }
|
||||
}
|
||||
.logo-anim-glow {
|
||||
animation: logo-glow-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes logo-shimmer {
|
||||
0% { background-position: -200% center; }
|
||||
100% { background-position: 200% center; }
|
||||
}
|
||||
.logo-anim-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-brand) 0%,
|
||||
color-mix(in srgb, var(--color-brand) 40%, white) 40%,
|
||||
var(--color-brand) 50%,
|
||||
color-mix(in srgb, var(--color-brand) 40%, white) 60%,
|
||||
var(--color-brand) 100%
|
||||
);
|
||||
background-size: 200% auto;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: logo-shimmer 2.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes logo-pulse-scale {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.06); }
|
||||
}
|
||||
.logo-anim-pulse {
|
||||
display: inline-block;
|
||||
animation: logo-pulse-scale 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes logo-rainbow {
|
||||
0% { filter: hue-rotate(0deg); }
|
||||
100% { filter: hue-rotate(360deg); }
|
||||
}
|
||||
.logo-anim-rainbow {
|
||||
animation: logo-rainbow 4s linear infinite;
|
||||
}
|
||||
|
||||
/* ── Respect reduced motion — disable all decorative animations ─────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32" />
|
||||
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
|
||||
<link rel="icon" type="image/png" href="/favicon-16.png" sizes="16x16" />
|
||||
|
||||
@@ -36,6 +36,14 @@ import type { Voice } from '$lib/types';
|
||||
|
||||
export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
|
||||
export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed';
|
||||
/**
|
||||
* 'stream' – Use /api/audio-stream: audio starts playing within seconds,
|
||||
* stream is saved to MinIO concurrently. No runner task needed.
|
||||
* 'generate' – Legacy mode: queue a runner task, poll until done, then play
|
||||
* from the presigned MinIO URL. Needed for CF AI voices which
|
||||
* do not support native streaming.
|
||||
*/
|
||||
export type AudioMode = 'stream' | 'generate';
|
||||
|
||||
class AudioStore {
|
||||
// ── What is loaded ──────────────────────────────────────────────────────
|
||||
@@ -46,6 +54,13 @@ class AudioStore {
|
||||
voice = $state('af_bella');
|
||||
speed = $state(1.0);
|
||||
|
||||
/**
|
||||
* Playback mode:
|
||||
* 'stream' – pipe from /api/audio-stream (low latency, saves concurrently)
|
||||
* 'generate' – queue runner task, poll, then play presigned URL (CF AI / legacy)
|
||||
*/
|
||||
audioMode = $state<AudioMode>('stream');
|
||||
|
||||
/** Cover image URL for the currently loaded book. */
|
||||
cover = $state('');
|
||||
|
||||
@@ -155,6 +170,21 @@ class AudioStore {
|
||||
return this.status === 'ready' || this.status === 'generating' || this.status === 'loading';
|
||||
}
|
||||
|
||||
/**
|
||||
* When true the persistent mini-bar in +layout.svelte is hidden.
|
||||
* Set by the chapter reader page when playerStyle is 'float' or 'minimal'
|
||||
* so the in-page player is the sole control surface.
|
||||
* Cleared when leaving the chapter page (page destroy / onDestroy effect).
|
||||
*/
|
||||
suppressMiniBar = $state(false);
|
||||
|
||||
/**
|
||||
* Position of the draggable float overlay (bottom-right anchor offsets).
|
||||
* Stored here (module singleton) so the position survives chapter navigation.
|
||||
* x > 0 = moved left; y > 0 = moved up.
|
||||
*/
|
||||
floatPos = $state({ x: 0, y: 0 });
|
||||
|
||||
/** True when the currently loaded track matches slug+chapter */
|
||||
isCurrentChapter(slug: string, chapter: number): boolean {
|
||||
return this.slug === slug && this.chapter === chapter;
|
||||
|
||||
@@ -50,10 +50,12 @@
|
||||
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { untrack } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/utils';
|
||||
import type { Voice } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import ChapterPickerOverlay from '$lib/components/ChapterPickerOverlay.svelte';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
@@ -70,8 +72,11 @@
|
||||
voices?: Voice[];
|
||||
/** Called when the server returns 402 (free daily limit reached). */
|
||||
onProRequired?: () => void;
|
||||
/** Visual style of the player card. 'standard' = inline card; 'float' = draggable overlay. */
|
||||
playerStyle?: 'standard' | 'float';
|
||||
/** Visual style of the player card.
|
||||
* 'standard' = full inline card with voice/chapter controls;
|
||||
* 'minimal' = compact single-row bar (play + seek + time only);
|
||||
* 'float' = draggable overlay anchored bottom-right. */
|
||||
playerStyle?: 'standard' | 'minimal' | 'float';
|
||||
/** Approximate word count for the chapter, used to show estimated listen time in the idle state. */
|
||||
wordCount?: number;
|
||||
}
|
||||
@@ -107,22 +112,10 @@
|
||||
|
||||
// ── Chapter picker state ─────────────────────────────────────────────────
|
||||
let showChapterPanel = $state(false);
|
||||
let chapterSearch = $state('');
|
||||
const filteredChapters = $derived(
|
||||
chapterSearch.trim() === ''
|
||||
? audioStore.chapters
|
||||
: audioStore.chapters.filter((ch) =>
|
||||
(ch.title || `Chapter ${ch.number}`)
|
||||
.toLowerCase()
|
||||
.includes(chapterSearch.toLowerCase()) ||
|
||||
String(ch.number).includes(chapterSearch)
|
||||
)
|
||||
);
|
||||
|
||||
function playChapter(chapterNumber: number) {
|
||||
audioStore.autoStartChapter = chapterNumber;
|
||||
showChapterPanel = false;
|
||||
chapterSearch = '';
|
||||
goto(`/books/${slug}/chapters/${chapterNumber}`);
|
||||
}
|
||||
|
||||
@@ -293,7 +286,7 @@
|
||||
// Close panels on Escape.
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (showChapterPanel) { showChapterPanel = false; chapterSearch = ''; }
|
||||
if (showChapterPanel) { showChapterPanel = false; }
|
||||
else { stopSample(); showVoicePanel = false; }
|
||||
}
|
||||
}
|
||||
@@ -613,41 +606,95 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Slow path: audio not yet in MinIO.
|
||||
//
|
||||
// For Kokoro / PocketTTS: always use the streaming endpoint so audio
|
||||
// starts playing within seconds. The stream handler checks MinIO first
|
||||
// (fast redirect if already cached) and otherwise generates + uploads
|
||||
// concurrently. Even if the async runner is already working on this
|
||||
// chapter, the stream will redirect to MinIO the moment the runner
|
||||
// finishes — no harmful double-generation occurs because the backend
|
||||
// deduplications via AudioExists on the next request.
|
||||
if (!voice.startsWith('cfai:')) {
|
||||
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
|
||||
// WAV (PCM) is natively supported on all platforms including iOS Safari.
|
||||
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
|
||||
const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts');
|
||||
const format = isPocketTTS ? 'wav' : 'mp3';
|
||||
const qs = new URLSearchParams({ voice, format });
|
||||
const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`;
|
||||
// HEAD probe: check paywall without triggering generation.
|
||||
const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null);
|
||||
if (headRes?.status === 402) {
|
||||
// Slow path: audio not yet in MinIO.
|
||||
//
|
||||
// For Kokoro / PocketTTS in 'stream' mode: use the streaming endpoint so
|
||||
// audio starts playing within seconds. The stream handler checks MinIO
|
||||
// first (fast redirect if already cached) and otherwise generates +
|
||||
// uploads concurrently.
|
||||
//
|
||||
// In 'generate' mode (user preference): queue a runner task and poll,
|
||||
// same as CF AI — audio plays only after the full file is ready in MinIO.
|
||||
if (!voice.startsWith('cfai:') && audioStore.audioMode === 'stream') {
|
||||
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
|
||||
// WAV (PCM) is natively supported on all platforms including iOS Safari.
|
||||
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
|
||||
const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts');
|
||||
const format = isPocketTTS ? 'wav' : 'mp3';
|
||||
const qs = new URLSearchParams({ voice, format });
|
||||
const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`;
|
||||
// HEAD probe: check paywall without triggering generation.
|
||||
const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null);
|
||||
if (headRes?.status === 402) {
|
||||
audioStore.status = 'idle';
|
||||
onProRequired?.();
|
||||
return;
|
||||
}
|
||||
audioStore.audioUrl = streamUrl;
|
||||
audioStore.status = 'ready';
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-CF AI voices in 'generate' mode: queue runner task, show progress,
|
||||
// wait for full audio in MinIO before playing (same as CF AI but no preview).
|
||||
if (!voice.startsWith('cfai:')) {
|
||||
audioStore.status = 'generating';
|
||||
audioStore.isPreview = false;
|
||||
startProgress();
|
||||
|
||||
if (!presignResult.enqueued) {
|
||||
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ voice })
|
||||
});
|
||||
|
||||
if (res.status === 402) {
|
||||
audioStore.status = 'idle';
|
||||
stopProgress();
|
||||
onProRequired?.();
|
||||
return;
|
||||
}
|
||||
audioStore.audioUrl = streamUrl;
|
||||
audioStore.status = 'ready';
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
|
||||
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
|
||||
|
||||
if (res.status === 200) {
|
||||
await res.body?.cancel();
|
||||
await finishProgress();
|
||||
const doneUrl = await tryPresign(slug, chapter, voice);
|
||||
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
|
||||
audioStore.audioUrl = doneUrl.url;
|
||||
audioStore.status = 'ready';
|
||||
restoreSavedAudioTime();
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
}
|
||||
// 202 — runner task enqueued, fall through to poll.
|
||||
}
|
||||
|
||||
// CF AI voices: use preview/swap strategy.
|
||||
// 1. Fetch a short ~1-2 min preview clip from the first text chunk
|
||||
// so playback starts immediately — no more waiting behind a spinner.
|
||||
// 2. Meanwhile keep polling the full audio job; when it finishes,
|
||||
// swap the <audio> src to the full URL preserving currentTime.
|
||||
const final = await pollAudioStatus(slug, chapter, voice);
|
||||
if (final.status === 'failed') {
|
||||
throw new Error(
|
||||
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
|
||||
);
|
||||
}
|
||||
|
||||
await finishProgress();
|
||||
const doneUrl = await tryPresign(slug, chapter, voice);
|
||||
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
|
||||
audioStore.audioUrl = doneUrl.url;
|
||||
audioStore.status = 'ready';
|
||||
restoreSavedAudioTime();
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
}
|
||||
|
||||
// CF AI voices: use preview/swap strategy.
|
||||
// 1. Fetch a short ~1-2 min preview clip from the first text chunk
|
||||
// so playback starts immediately — no more waiting behind a spinner.
|
||||
// 2. Meanwhile keep polling the full audio job; when it finishes,
|
||||
// swap the <audio> src to the full URL preserving currentTime.
|
||||
audioStore.status = 'generating';
|
||||
audioStore.isPreview = false;
|
||||
startProgress();
|
||||
@@ -899,24 +946,86 @@
|
||||
}
|
||||
|
||||
// ── Float player drag state ──────────────────────────────────────────────
|
||||
/** Position of the floating overlay (bottom-right anchor by default). */
|
||||
let floatPos = $state({ x: 0, y: 0 });
|
||||
// floatPos lives on audioStore (singleton) so position survives chapter navigation.
|
||||
// Coordinate system: x/y are offsets from bottom-right corner (positive = toward center).
|
||||
// right = calc(1rem + {-x}px) → x=0 means right:1rem, x=-50 means right:3.125rem
|
||||
// bottom = calc(1rem + {-y}px) → y=0 means bottom:1rem
|
||||
//
|
||||
// To keep the circle in the viewport we clamp so that the element never goes
|
||||
// outside any edge. Circle size = 56px (w-14), margin = 16px (1rem).
|
||||
|
||||
const FLOAT_SIZE = 56; // px — must match w-14
|
||||
const FLOAT_MARGIN = 16; // px — 1rem
|
||||
|
||||
function clampFloatPos(x: number, y: number): { x: number; y: number } {
|
||||
const vw = typeof window !== 'undefined' ? window.innerWidth : 400;
|
||||
const vh = typeof window !== 'undefined' ? window.innerHeight : 800;
|
||||
// right edge: element right = 1rem - x ≥ 0 → x ≤ FLOAT_MARGIN
|
||||
const maxX = FLOAT_MARGIN;
|
||||
// left edge: element right + size ≤ vw → right = 1rem - x → 1rem - x + size ≤ vw
|
||||
// x ≥ FLOAT_MARGIN + FLOAT_SIZE - vw
|
||||
const minX = FLOAT_MARGIN + FLOAT_SIZE - vw;
|
||||
// top edge: element bottom + size ≤ vh → bottom = 1rem - y → 1rem - y + size ≤ vh
|
||||
// y ≥ FLOAT_MARGIN + FLOAT_SIZE - vh
|
||||
const minY = FLOAT_MARGIN + FLOAT_SIZE - vh;
|
||||
// bottom edge: element bottom = 1rem - y ≥ 0 → y ≤ FLOAT_MARGIN
|
||||
const maxY = FLOAT_MARGIN;
|
||||
return {
|
||||
x: Math.max(minX, Math.min(maxX, x)),
|
||||
y: Math.max(minY, Math.min(maxY, y)),
|
||||
};
|
||||
}
|
||||
|
||||
let floatDragging = $state(false);
|
||||
let floatDragStart = $state({ mx: 0, my: 0, ox: 0, oy: 0 });
|
||||
// Track total pointer movement to distinguish tap vs drag
|
||||
let floatMoved = $state(false);
|
||||
|
||||
function onFloatPointerDown(e: PointerEvent) {
|
||||
e.stopPropagation();
|
||||
floatDragging = true;
|
||||
floatDragStart = { mx: e.clientX, my: e.clientY, ox: floatPos.x, oy: floatPos.y };
|
||||
floatMoved = false;
|
||||
floatDragStart = { mx: e.clientX, my: e.clientY, ox: audioStore.floatPos.x, oy: audioStore.floatPos.y };
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
function onFloatPointerMove(e: PointerEvent) {
|
||||
if (!floatDragging) return;
|
||||
floatPos = {
|
||||
x: floatDragStart.ox + (e.clientX - floatDragStart.mx),
|
||||
y: floatDragStart.oy + (e.clientY - floatDragStart.my)
|
||||
const dx = e.clientX - floatDragStart.mx;
|
||||
const dy = e.clientY - floatDragStart.my;
|
||||
// Only start moving if dragged > 6px to preserve tap detection
|
||||
if (!floatMoved && Math.hypot(dx, dy) < 6) return;
|
||||
floatMoved = true;
|
||||
// right = MARGIN - x → drag right (dx>0) should decrease right → x increases → x = ox + dx
|
||||
// bottom = MARGIN - y → drag down (dy>0) should decrease bottom → y increases → y = oy + dy
|
||||
const raw = {
|
||||
x: floatDragStart.ox + dx,
|
||||
y: floatDragStart.oy + dy,
|
||||
};
|
||||
audioStore.floatPos = clampFloatPos(raw.x, raw.y);
|
||||
}
|
||||
function onFloatPointerUp() { floatDragging = false; }
|
||||
function onFloatPointerUp(e: PointerEvent) {
|
||||
if (!floatDragging) return;
|
||||
if (floatDragging && !floatMoved) {
|
||||
// Tap: toggle play/pause
|
||||
audioStore.toggleRequest++;
|
||||
}
|
||||
floatDragging = false;
|
||||
try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Clamp saved position to viewport on mount and on resize.
|
||||
// Use untrack() when reading floatPos to avoid a reactive loop
|
||||
// (reading + writing the same state inside $effect would re-trigger forever).
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const clamp = () => {
|
||||
const { x, y } = untrack(() => audioStore.floatPos);
|
||||
audioStore.floatPos = clampFloatPos(x, y);
|
||||
};
|
||||
clamp();
|
||||
window.addEventListener('resize', clamp);
|
||||
return () => window.removeEventListener('resize', clamp);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
@@ -991,7 +1100,8 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Track info -->
|
||||
<!-- Track info (hidden in minimal style) -->
|
||||
{#if playerStyle !== 'minimal'}
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-(--color-text) leading-tight truncate">
|
||||
{m.reader_play_narration()}
|
||||
@@ -1001,7 +1111,7 @@
|
||||
{#if voices.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; }}
|
||||
class={cn('flex items-center gap-1 text-xs transition-colors leading-none', showVoicePanel ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
title={m.reader_change_voice()}
|
||||
>
|
||||
@@ -1019,11 +1129,39 @@
|
||||
{#if voices.length > 0}<span class="text-(--color-border) text-xs leading-none">·</span>{/if}
|
||||
<span class="text-xs text-(--color-muted) leading-none tabular-nums">~{estimatedMinutes} min</span>
|
||||
{/if}
|
||||
<!-- Stream / Generate mode toggle -->
|
||||
{#if !audioStore.voice.startsWith('cfai:')}
|
||||
<span class="text-(--color-border) text-xs leading-none">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream'; }}
|
||||
class={cn(
|
||||
'flex items-center gap-0.5 text-xs leading-none transition-colors',
|
||||
audioStore.audioMode === 'stream'
|
||||
? 'text-(--color-brand)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
title={audioStore.audioMode === 'stream' ? 'Stream mode — click to switch to generate' : 'Generate mode — click to switch to stream'}
|
||||
>
|
||||
{#if audioStore.audioMode === 'stream'}
|
||||
<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Stream
|
||||
{:else}
|
||||
<svg class="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
Generate
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Chapters button (right side) -->
|
||||
{#if chapters.length > 0}
|
||||
<!-- Chapters button (right side, hidden in minimal style) -->
|
||||
{#if chapters.length > 0 && playerStyle !== 'minimal'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterPanel = !showChapterPanel; showVoicePanel = false; stopSample(); }}
|
||||
@@ -1097,6 +1235,67 @@
|
||||
{:else}
|
||||
<!-- ── Non-idle states (loading / generating / ready / other-chapter-playing) ── -->
|
||||
{#if !(playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active)}
|
||||
|
||||
{#if playerStyle === 'minimal' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active}
|
||||
<!-- ── Minimal style: compact bar — seek + play/pause + skip + time ────────── -->
|
||||
<div class="px-3 py-2.5 flex items-center gap-2">
|
||||
<!-- Skip back 15s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
|
||||
class="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||
title="-15s"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Play/pause -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.toggleRequest++; }}
|
||||
class="flex-shrink-0 w-8 h-8 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) active:scale-95 transition-all"
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Skip forward 30s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
|
||||
class="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||
title="+30s"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Seek bar — proper range input so drag works on iOS too -->
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Seek"
|
||||
min="0"
|
||||
max={audioStore.duration || 0}
|
||||
value={audioStore.currentTime}
|
||||
oninput={(e) => { audioStore.seekRequest = parseFloat((e.target as HTMLInputElement).value); }}
|
||||
onchange={(e) => { audioStore.seekRequest = parseFloat((e.target as HTMLInputElement).value); }}
|
||||
class="flex-1 h-1.5 cursor-pointer"
|
||||
style="accent-color: var(--color-brand);"
|
||||
/>
|
||||
|
||||
<!-- Time -->
|
||||
<span class="flex-shrink-0 text-[11px] tabular-nums text-(--color-muted)">
|
||||
{formatTime(audioStore.currentTime)}<span class="opacity-40">/</span>{formatDuration(audioStore.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-end gap-2 mb-3">
|
||||
<!-- Chapter picker button -->
|
||||
@@ -1119,7 +1318,7 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; }}
|
||||
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
|
||||
title={m.reader_change_voice()}
|
||||
>
|
||||
@@ -1302,182 +1501,103 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- ── Chapter picker overlay ─────────────────────────────────────────────────
|
||||
Rendered as a top-level sibling (outside all player containers) so that
|
||||
the fixed inset-0 positioning is never clipped by overflow-hidden or
|
||||
border-radius on any ancestor wrapping the AudioPlayer component. -->
|
||||
{#if showChapterPanel && audioStore.chapters.length > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[60] flex flex-col"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||
<span class="text-sm font-semibold text-(--color-text) flex-1">Chapters</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterPanel = false; chapterSearch = ''; }}
|
||||
class="w-9 h-9 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close chapter picker"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Search -->
|
||||
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
bind:value={chapterSearch}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Chapter list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(ch.number)}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
|
||||
ch.number === chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<!-- Chapter number badge -->
|
||||
<span class={cn(
|
||||
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
|
||||
ch.number === chapter
|
||||
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
|
||||
: 'border-(--color-border) text-(--color-muted)'
|
||||
)}>{ch.number}</span>
|
||||
<!-- Title -->
|
||||
<span class={cn(
|
||||
'flex-1 text-sm truncate',
|
||||
ch.number === chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
|
||||
)}>{ch.title || `Chapter ${ch.number}`}</span>
|
||||
<!-- Now-playing indicator -->
|
||||
{#if ch.number === chapter}
|
||||
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if filteredChapters.length === 0}
|
||||
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<ChapterPickerOverlay
|
||||
chapters={audioStore.chapters}
|
||||
activeChapter={audioStore.chapter}
|
||||
zIndex="z-[60]"
|
||||
onselect={playChapter}
|
||||
onclose={() => { showChapterPanel = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- ── Float player overlay ──────────────────────────────────────────────────
|
||||
Rendered outside all containers so fixed positioning is never clipped.
|
||||
A draggable circle anchored to the viewport.
|
||||
Tap = toggle play/pause.
|
||||
Drag = reposition (clamped to viewport).
|
||||
Visible when playerStyle='float' and audio is active for this chapter. -->
|
||||
{#if playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed z-[55] select-none"
|
||||
style="
|
||||
bottom: calc(4.5rem + {-floatPos.y}px);
|
||||
right: calc(1rem + {-floatPos.x}px);
|
||||
bottom: calc({FLOAT_MARGIN}px + {-audioStore.floatPos.y}px);
|
||||
right: calc({FLOAT_MARGIN}px + {-audioStore.floatPos.x}px);
|
||||
touch-action: none;
|
||||
width: {FLOAT_SIZE}px;
|
||||
height: {FLOAT_SIZE}px;
|
||||
"
|
||||
onpointerdown={onFloatPointerDown}
|
||||
onpointermove={onFloatPointerMove}
|
||||
onpointerup={onFloatPointerUp}
|
||||
onpointercancel={(e) => { floatDragging = false; try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* ignore */ } }}
|
||||
>
|
||||
<div class="w-64 rounded-2xl bg-(--color-surface) border border-(--color-border) shadow-2xl overflow-hidden">
|
||||
<!-- Drag handle + title row -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 pt-2.5 pb-1 cursor-grab active:cursor-grabbing"
|
||||
onpointerdown={onFloatPointerDown}
|
||||
onpointermove={onFloatPointerMove}
|
||||
onpointerup={onFloatPointerUp}
|
||||
onpointercancel={onFloatPointerUp}
|
||||
>
|
||||
<!-- Drag grip dots -->
|
||||
<svg class="w-3.5 h-3.5 text-(--color-muted)/50 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/>
|
||||
<circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/>
|
||||
<circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
|
||||
<!-- Pulsing ring when playing -->
|
||||
{#if audioStore.isPlaying}
|
||||
<span class="absolute inset-0 rounded-full bg-(--color-brand)/30 animate-ping pointer-events-none"></span>
|
||||
{/if}
|
||||
|
||||
<!-- Circle button -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-full bg-(--color-brand) shadow-xl flex items-center justify-center {floatDragging ? 'cursor-grabbing' : 'cursor-grab'} transition-transform active:scale-95"
|
||||
>
|
||||
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
|
||||
<!-- Spinner -->
|
||||
<svg class="w-6 h-6 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-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span class="flex-1 text-xs font-medium text-(--color-muted) truncate">
|
||||
{audioStore.chapterTitle || `Chapter ${audioStore.chapter}`}
|
||||
</span>
|
||||
<!-- Status dot -->
|
||||
{#if audioStore.isPlaying}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) flex-shrink-0 animate-pulse"></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Seek bar -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div
|
||||
role="none"
|
||||
class="mx-3 mb-2 h-1 bg-(--color-surface-3) rounded-full overflow-hidden cursor-pointer"
|
||||
onclick={seekFromBar}
|
||||
>
|
||||
<div class="h-full bg-(--color-brand) rounded-full transition-none" style="width: {playPct}%"></div>
|
||||
</div>
|
||||
|
||||
<!-- Controls row -->
|
||||
<div class="flex items-center gap-1 px-3 pb-2.5">
|
||||
<!-- Skip back 15s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex-shrink-0"
|
||||
title="-15s"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Play/pause -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.toggleRequest++; }}
|
||||
class="w-9 h-9 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) active:scale-95 transition-all flex-shrink-0"
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Skip forward 30s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
|
||||
class="w-8 h-8 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex-shrink-0"
|
||||
title="+30s"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Time -->
|
||||
<span class="flex-1 text-[11px] text-center tabular-nums text-(--color-muted)">
|
||||
{formatTime(audioStore.currentTime)}
|
||||
<span class="opacity-50">/</span>
|
||||
{formatDuration(audioStore.duration)}
|
||||
</span>
|
||||
|
||||
<!-- Speed -->
|
||||
<span class="text-[11px] font-medium tabular-nums text-(--color-muted) flex-shrink-0">
|
||||
{audioStore.speed}×
|
||||
</span>
|
||||
</div>
|
||||
{:else if audioStore.isPlaying}
|
||||
<!-- Pause icon -->
|
||||
<svg class="w-6 h-6 text-white pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Play icon -->
|
||||
<svg class="w-6 h-6 text-white ml-0.5 pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Progress arc ring (thin, overlaid on circle edge) -->
|
||||
{#if audioStore.duration > 0}
|
||||
{@const r = 26}
|
||||
{@const circ = 2 * Math.PI * r}
|
||||
{@const dash = (audioStore.currentTime / audioStore.duration) * circ}
|
||||
<svg
|
||||
class="absolute inset-0 pointer-events-none -rotate-90"
|
||||
width={FLOAT_SIZE}
|
||||
height={FLOAT_SIZE}
|
||||
viewBox="0 0 {FLOAT_SIZE} {FLOAT_SIZE}"
|
||||
>
|
||||
<circle
|
||||
cx={FLOAT_SIZE / 2}
|
||||
cy={FLOAT_SIZE / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.25)"
|
||||
stroke-width="2.5"
|
||||
/>
|
||||
<circle
|
||||
cx={FLOAT_SIZE / 2}
|
||||
cy={FLOAT_SIZE / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="white"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="{circ}"
|
||||
stroke-dashoffset="{circ - dash}"
|
||||
style="transition: stroke-dashoffset 0.5s linear;"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
132
ui/src/lib/components/ChapterPickerOverlay.svelte
Normal file
132
ui/src/lib/components/ChapterPickerOverlay.svelte
Normal file
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface ChapterMeta {
|
||||
number: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Full chapter list to render and filter. */
|
||||
chapters: ChapterMeta[];
|
||||
/** Number of the currently-active chapter (highlighted + auto-scrolled). */
|
||||
activeChapter: number;
|
||||
/** z-index class, e.g. "z-[60]" or "z-[80]". Defaults to "z-[60]". */
|
||||
zIndex?: string;
|
||||
/** Called when a chapter row is tapped. The overlay does NOT close itself. */
|
||||
onselect: (chapterNumber: number) => void;
|
||||
/** Called when the close (✕) button is tapped. */
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
chapters,
|
||||
activeChapter,
|
||||
zIndex = 'z-[60]',
|
||||
onselect,
|
||||
onclose
|
||||
}: Props = $props();
|
||||
|
||||
let search = $state('');
|
||||
|
||||
const filtered = $derived(
|
||||
search.trim() === ''
|
||||
? chapters
|
||||
: chapters.filter((ch) =>
|
||||
(ch.title || `Chapter ${ch.number}`)
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()) ||
|
||||
String(ch.number).includes(search)
|
||||
)
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
search = '';
|
||||
onclose();
|
||||
}
|
||||
|
||||
function handleSelect(n: number) {
|
||||
search = '';
|
||||
onselect(n);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 flex flex-col {zIndex}"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0"
|
||||
style="padding-top: max(0.75rem, env(safe-area-inset-top));"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClose}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close chapter picker"
|
||||
>
|
||||
<!-- close / ✕ -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
bind:value={search}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter list -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto overscroll-contain"
|
||||
style="padding-bottom: env(safe-area-inset-bottom);"
|
||||
>
|
||||
{#each filtered as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelect(ch.number)}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
|
||||
ch.number === activeChapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<span class={cn(
|
||||
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
|
||||
ch.number === activeChapter
|
||||
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
|
||||
: 'border-(--color-border) text-(--color-muted)'
|
||||
)}>{ch.number}</span>
|
||||
|
||||
<span class={cn(
|
||||
'flex-1 text-sm truncate',
|
||||
ch.number === activeChapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
|
||||
)}>{ch.title || `Chapter ${ch.number}`}</span>
|
||||
|
||||
{#if ch.number === activeChapter}
|
||||
<!-- play icon -->
|
||||
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{search}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
193
ui/src/lib/components/CurrentlyReadingModal.svelte
Normal file
193
ui/src/lib/components/CurrentlyReadingModal.svelte
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Book {
|
||||
slug: string;
|
||||
title: string;
|
||||
cover?: string;
|
||||
author?: string;
|
||||
genres?: string[] | string;
|
||||
}
|
||||
|
||||
interface ReadingEntry {
|
||||
book: Book;
|
||||
chapter: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** The slug of the book currently being read — highlighted in the list. */
|
||||
currentSlug: string;
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { currentSlug, onclose }: Props = $props();
|
||||
|
||||
let entries = $state<ReadingEntry[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
// ── Fetch in-progress books on mount ──────────────────────────────────────
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/home');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json() as { continue_reading: ReadingEntry[] };
|
||||
entries = data.continue_reading ?? [];
|
||||
} catch {
|
||||
error = 'Failed to load your reading list.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
// ── Body scroll lock ──────────────────────────────────────────────────────
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
});
|
||||
|
||||
// ── Keyboard: Escape closes ───────────────────────────────────────────────
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onclose();
|
||||
}
|
||||
|
||||
// ── Navigate to a book's current chapter ─────────────────────────────────
|
||||
function openBook(entry: ReadingEntry) {
|
||||
onclose();
|
||||
goto(`/books/${entry.book.slug}/chapters/${entry.chapter}`);
|
||||
}
|
||||
|
||||
function parseGenres(genres: string[] | string | undefined): string[] {
|
||||
if (!genres) return [];
|
||||
if (Array.isArray(genres)) return genres;
|
||||
try { const p = JSON.parse(genres); return Array.isArray(p) ? p : []; } catch { return []; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[70] flex flex-col"
|
||||
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
|
||||
onpointerdown={(e) => { if (e.target === e.currentTarget) onclose(); }}
|
||||
>
|
||||
<!-- Panel — matches SearchModal style -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="w-full max-w-2xl mx-auto mt-0 sm:mt-16 flex flex-col bg-(--color-surface) sm:rounded-2xl border-b sm:border border-(--color-border) shadow-2xl overflow-hidden"
|
||||
style="max-height: 100svh;"
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header row -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||
<svg class="w-5 h-5 text-(--color-muted) shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
|
||||
<span class="flex-1 text-base font-semibold text-(--color-text)">Currently Reading</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onclose}
|
||||
class="shrink-0 px-3 py-1 rounded-lg text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable body -->
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain">
|
||||
|
||||
{#if loading}
|
||||
<!-- Loading skeleton -->
|
||||
<div class="px-4 pt-3 pb-4 space-y-1">
|
||||
{#each [1, 2, 3] as _}
|
||||
<div class="flex items-center gap-3 px-0 py-3 border-b border-(--color-border)/40">
|
||||
<div class="shrink-0 w-10 h-14 rounded bg-(--color-surface-3) animate-pulse"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-3.5 bg-(--color-surface-3) rounded animate-pulse w-3/4"></div>
|
||||
<div class="h-3 bg-(--color-surface-3) rounded animate-pulse w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{:else if error}
|
||||
<p class="px-5 py-8 text-sm text-center text-(--color-danger)">{error}</p>
|
||||
|
||||
{:else if entries.length === 0}
|
||||
<div class="px-5 py-12 text-center">
|
||||
<p class="text-sm font-semibold text-(--color-text) mb-1">No books in progress</p>
|
||||
<p class="text-xs text-(--color-muted)">Books you start reading will appear here.</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
{#each entries as entry, i}
|
||||
{@const genres = parseGenres(entry.book.genres)}
|
||||
{@const isCurrent = entry.book.slug === currentSlug}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openBook(entry)}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors border-b border-(--color-border)/40 last:border-0',
|
||||
isCurrent ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<!-- Cover thumbnail -->
|
||||
<div class="shrink-0 w-10 h-14 rounded overflow-hidden bg-(--color-surface-2) border border-(--color-border) relative">
|
||||
{#if entry.book.cover}
|
||||
<img src={entry.book.cover} alt="" class="w-full h-full object-cover" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-(--color-muted)/40" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start gap-2">
|
||||
<p class={cn(
|
||||
'text-sm font-semibold leading-snug line-clamp-1 flex-1',
|
||||
isCurrent ? 'text-(--color-brand)' : 'text-(--color-text)'
|
||||
)}>
|
||||
{entry.book.title}
|
||||
</p>
|
||||
{#if isCurrent}
|
||||
<span class="shrink-0 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 leading-none mt-0.5">
|
||||
Now
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if entry.book.author}
|
||||
<p class="text-xs text-(--color-muted) mt-0.5 truncate">{entry.book.author}</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||
<span class="text-xs text-(--color-muted)/60">Ch. {entry.chapter}</span>
|
||||
{#each genres.slice(0, 2) as g}
|
||||
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{g}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chevron (dimmed for current, normal for others) -->
|
||||
<svg class={cn('w-4 h-4 shrink-0', isCurrent ? 'text-(--color-brand)/40' : 'text-(--color-muted)/40')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,7 +2,9 @@
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
import { goto } from '$app/navigation';
|
||||
import { fly } from 'svelte/transition';
|
||||
import type { Voice } from '$lib/types';
|
||||
import ChapterPickerOverlay from '$lib/components/ChapterPickerOverlay.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Called when the user closes the overlay. */
|
||||
@@ -92,25 +94,13 @@
|
||||
const filteredCfai = $derived(cfaiVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
|
||||
|
||||
// ── Chapter search ────────────────────────────────────────────────────────
|
||||
let chapterSearch = $state('');
|
||||
// (search state is managed internally by ChapterPickerOverlay)
|
||||
|
||||
// Scroll the current chapter into view instantly (no animation) when the
|
||||
// chapter modal opens. Applied to every chapter button; only scrolls when
|
||||
// the chapter number matches the currently playing one. Runs once on mount
|
||||
// before the browser paints so no scroll animation is ever visible.
|
||||
function scrollIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
// ── Chapter click-to-play ─────────────────────────────────────────────────
|
||||
function playChapter(chapterNumber: number) {
|
||||
audioStore.autoStartChapter = chapterNumber;
|
||||
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
|
||||
}
|
||||
const filteredChapters = $derived(
|
||||
chapterSearch.trim() === ''
|
||||
? audioStore.chapters
|
||||
: audioStore.chapters.filter((ch) =>
|
||||
(ch.title || `Chapter ${ch.number}`)
|
||||
.toLowerCase()
|
||||
.includes(chapterSearch.toLowerCase()) ||
|
||||
String(ch.number).includes(chapterSearch)
|
||||
)
|
||||
);
|
||||
|
||||
function voiceLabel(v: Voice | string): string {
|
||||
if (typeof v === 'string') {
|
||||
@@ -156,13 +146,6 @@
|
||||
voiceSearch = '';
|
||||
}
|
||||
|
||||
// ── Chapter click-to-play ─────────────────────────────────────────────────
|
||||
function playChapter(chapterNumber: number) {
|
||||
audioStore.autoStartChapter = chapterNumber;
|
||||
onclose();
|
||||
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
|
||||
}
|
||||
|
||||
// ── Speed ────────────────────────────────────────────────────────────────
|
||||
const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const;
|
||||
|
||||
@@ -247,6 +230,7 @@
|
||||
<!-- Full-screen listening mode overlay -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
transition:fly={{ y: '100%', duration: 320, opacity: 1 }}
|
||||
bind:this={overlayEl}
|
||||
class="fixed inset-0 z-60 flex flex-col overflow-hidden"
|
||||
style="
|
||||
@@ -451,67 +435,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Chapter modal (full-screen overlay) -->
|
||||
{#if showChapterModal && audioStore.chapters.length > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[80] flex flex-col"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0" style="padding-top: max(0.75rem, env(safe-area-inset-top));">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterModal = false; }}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close chapter picker"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
|
||||
</div>
|
||||
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
bind:value={chapterSearch}
|
||||
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain" style="padding-bottom: env(safe-area-inset-bottom);">
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(ch.number)}
|
||||
use:scrollIfActive={ch.number === audioStore.chapter}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
|
||||
ch.number === audioStore.chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<span class={cn(
|
||||
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
|
||||
ch.number === audioStore.chapter ? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)' : 'border-(--color-border) text-(--color-muted)'
|
||||
)}>{ch.number}</span>
|
||||
<span class={cn('flex-1 text-sm truncate', ch.number === audioStore.chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)')}>{ch.title || `Chapter ${ch.number}`}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{#if filteredChapters.length === 0}
|
||||
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Controls area (bottom half) ───────────────────────────────────── -->
|
||||
<div class="flex-1 flex flex-col justify-end px-6 pb-6 gap-0 shrink-0 overflow-hidden" style="z-index: 2; position: relative;">
|
||||
|
||||
@@ -664,24 +587,57 @@
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Announce chapter pill (only meaningful when auto-next is on) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
audioStore.announceChapter
|
||||
<!-- Announce chapter pill (only meaningful when auto-next is on) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
audioStore.announceChapter
|
||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={audioStore.announceChapter}
|
||||
title={audioStore.announceChapter ? 'Chapter announcing on' : 'Chapter announcing off'}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
</svg>
|
||||
Announce
|
||||
</button>
|
||||
|
||||
<!-- Stream / Generate mode toggle -->
|
||||
<!-- CF AI voices are batch-only and always use generate mode regardless of this setting -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
if (!audioStore.voice.startsWith('cfai:')) {
|
||||
audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream';
|
||||
}
|
||||
}}
|
||||
disabled={audioStore.voice.startsWith('cfai:')}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
audioStore.voice.startsWith('cfai:')
|
||||
? 'border-(--color-border) bg-(--color-surface-2) text-(--color-border) cursor-not-allowed opacity-50'
|
||||
: audioStore.audioMode === 'stream'
|
||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={audioStore.announceChapter}
|
||||
title={audioStore.announceChapter ? 'Chapter announcing on' : 'Chapter announcing off'}
|
||||
>
|
||||
)}
|
||||
aria-pressed={audioStore.audioMode === 'stream'}
|
||||
title={audioStore.voice.startsWith('cfai:') ? 'CF AI voices always use generate mode' : audioStore.audioMode === 'stream' ? 'Stream mode — audio starts instantly' : 'Generate mode — wait for full audio before playing'}
|
||||
>
|
||||
{#if audioStore.audioMode === 'stream' && !audioStore.voice.startsWith('cfai:')}
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Announce
|
||||
</button>
|
||||
{:else}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
{/if}
|
||||
{audioStore.audioMode === 'stream' && !audioStore.voice.startsWith('cfai:') ? 'Stream' : 'Generate'}
|
||||
</button>
|
||||
|
||||
<!-- Sleep timer pill -->
|
||||
<button
|
||||
@@ -704,3 +660,16 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter picker rendered OUTSIDE the transformed overlay so that
|
||||
fixed inset-0 anchors to the real viewport, not the CSS-transformed
|
||||
containing block (transform: translateY breaks fixed positioning). -->
|
||||
{#if showChapterModal && audioStore.chapters.length > 0}
|
||||
<ChapterPickerOverlay
|
||||
chapters={audioStore.chapters}
|
||||
activeChapter={audioStore.chapter}
|
||||
zIndex="z-[80]"
|
||||
onselect={playChapter}
|
||||
onclose={() => { showChapterModal = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
184
ui/src/lib/components/NotificationsModal.svelte
Normal file
184
ui/src/lib/components/NotificationsModal.svelte
Normal file
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
link: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
notifications: Notification[];
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
onclose: () => void;
|
||||
onMarkRead: (id: string) => void;
|
||||
onMarkAllRead: () => void;
|
||||
onDismiss: (id: string) => void;
|
||||
onClearAll: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
notifications,
|
||||
userId,
|
||||
isAdmin,
|
||||
onclose,
|
||||
onMarkRead,
|
||||
onMarkAllRead,
|
||||
onDismiss,
|
||||
onClearAll,
|
||||
}: Props = $props();
|
||||
|
||||
let filter = $state<'all' | 'unread'>('all');
|
||||
|
||||
const filtered = $derived(
|
||||
filter === 'unread' ? notifications.filter(n => !n.read) : notifications
|
||||
);
|
||||
const unreadCount = $derived(notifications.filter(n => !n.read).length);
|
||||
|
||||
// Body scroll lock + Escape to close
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}
|
||||
});
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onclose();
|
||||
}
|
||||
|
||||
const viewAllHref = $derived(isAdmin ? '/admin/notifications' : '/notifications');
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[70] flex flex-col"
|
||||
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
|
||||
onpointerdown={(e) => { if (e.target === e.currentTarget) onclose(); }}
|
||||
>
|
||||
<!-- Modal panel — slides down from top -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="w-full max-w-2xl mx-auto mt-0 sm:mt-16 flex flex-col bg-(--color-surface) sm:rounded-2xl border-b sm:border border-(--color-border) shadow-2xl overflow-hidden"
|
||||
style="max-height: 100svh;"
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header row -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-base font-semibold text-(--color-text)">Notifications</span>
|
||||
{#if unreadCount > 0}
|
||||
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-brand) text-black leading-none">
|
||||
{unreadCount}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
{#if unreadCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onMarkAllRead}
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors px-2 py-1 rounded hover:bg-(--color-surface-2)"
|
||||
>Mark all read</button>
|
||||
{/if}
|
||||
{#if notifications.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClearAll}
|
||||
class="text-xs text-(--color-muted) hover:text-red-400 transition-colors px-2 py-1 rounded hover:bg-(--color-surface-2)"
|
||||
>Clear all</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onclose}
|
||||
class="shrink-0 px-3 py-1 rounded-lg text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close notifications"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter tabs -->
|
||||
<div class="flex gap-0 px-4 py-2 border-b border-(--color-border)/60 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filter = 'all'}
|
||||
class={cn(
|
||||
'text-xs px-3 py-1.5 rounded-l border border-(--color-border) transition-colors',
|
||||
filter === 'all'
|
||||
? 'bg-(--color-brand) text-black border-(--color-brand) font-semibold'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
>All ({notifications.length})</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filter = 'unread'}
|
||||
class={cn(
|
||||
'text-xs px-3 py-1.5 rounded-r border border-l-0 border-(--color-border) transition-colors',
|
||||
filter === 'unread'
|
||||
? 'bg-(--color-brand) text-black border-(--color-brand) font-semibold'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
>Unread ({unreadCount})</button>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable list -->
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain min-h-0">
|
||||
{#if filtered.length === 0}
|
||||
<div class="py-16 text-center text-(--color-muted) text-sm">
|
||||
{filter === 'unread' ? 'No unread notifications' : 'No notifications yet'}
|
||||
</div>
|
||||
{:else}
|
||||
{#each filtered as n (n.id)}
|
||||
<div class={cn(
|
||||
'flex items-start gap-1 border-b border-(--color-border)/40 last:border-0 hover:bg-(--color-surface-2) group transition-colors',
|
||||
n.read && 'opacity-60'
|
||||
)}>
|
||||
<a
|
||||
href={n.link || (isAdmin ? '/admin' : '/')}
|
||||
onclick={() => { onMarkRead(n.id); onclose(); }}
|
||||
class="flex-1 px-4 py-3.5 min-w-0"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if !n.read}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) shrink-0"></span>
|
||||
{/if}
|
||||
<span class="text-sm font-semibold text-(--color-text) truncate">{n.title}</span>
|
||||
</div>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5 line-clamp-2">{n.message}</p>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onDismiss(n.id)}
|
||||
class="shrink-0 p-3 text-(--color-muted) hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-4 py-3 border-t border-(--color-border)/40 shrink-0">
|
||||
<a
|
||||
href={viewAllHref}
|
||||
onclick={onclose}
|
||||
class="block text-center text-sm text-(--color-muted) hover:text-(--color-brand) transition-colors"
|
||||
>View all notifications</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,26 +223,13 @@
|
||||
bind:value={query}
|
||||
type="search"
|
||||
placeholder="Search books, authors, genres…"
|
||||
class="flex-1 bg-transparent text-(--color-text) placeholder:text-(--color-muted) text-base focus:outline-none min-w-0"
|
||||
class="flex-1 bg-transparent text-(--color-text) placeholder:text-(--color-muted) text-base focus:outline-none min-w-0 [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); submitQuery(); } }}
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck={false}
|
||||
/>
|
||||
|
||||
{#if query}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { query = ''; inputEl?.focus(); }}
|
||||
class="shrink-0 p-1 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onclose}
|
||||
|
||||
285
ui/src/lib/components/SeasonalDecoration.svelte
Normal file
285
ui/src/lib/components/SeasonalDecoration.svelte
Normal file
@@ -0,0 +1,285 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* SeasonalDecoration — full-viewport canvas particle overlay.
|
||||
*
|
||||
* Modes:
|
||||
* snow — white circular snowflakes drifting down with gentle sway
|
||||
* sakura — pink/white ellipse petals falling and rotating
|
||||
* fireflies — small glowing dots floating up, pulsing opacity
|
||||
* leaves — orange/red/yellow tear-drop shapes tumbling down
|
||||
* stars — white stars twinkling in place (fixed positions, opacity animation)
|
||||
*/
|
||||
|
||||
type Mode = 'snow' | 'sakura' | 'fireflies' | 'leaves' | 'stars';
|
||||
|
||||
interface Props { mode: Mode }
|
||||
let { mode }: Props = $props();
|
||||
|
||||
let canvas = $state<HTMLCanvasElement | null>(null);
|
||||
let raf = 0;
|
||||
|
||||
// ── Particle types ──────────────────────────────────────────────────────
|
||||
|
||||
interface Particle {
|
||||
x: number; y: number; r: number;
|
||||
vx: number; vy: number;
|
||||
angle: number; vAngle: number;
|
||||
opacity: number; vOpacity: number;
|
||||
color: string;
|
||||
// star-specific
|
||||
twinkleOffset?: number;
|
||||
}
|
||||
|
||||
// ── Palette helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function rand(min: number, max: number) { return min + Math.random() * (max - min); }
|
||||
function randInt(min: number, max: number) { return Math.floor(rand(min, max + 1)); }
|
||||
|
||||
const SNOW_COLORS = ['rgba(255,255,255,0.85)', 'rgba(200,220,255,0.75)', 'rgba(220,235,255,0.8)'];
|
||||
const SAKURA_COLORS = ['rgba(255,182,193,0.85)', 'rgba(255,200,210,0.8)', 'rgba(255,240,245,0.9)', 'rgba(255,160,180,0.75)'];
|
||||
const FIREFLY_COLORS = ['rgba(180,255,100,0.9)', 'rgba(220,255,150,0.85)', 'rgba(255,255,180,0.8)'];
|
||||
const LEAF_COLORS = ['rgba(210,80,20,0.85)', 'rgba(190,120,30,0.8)', 'rgba(220,160,40,0.85)', 'rgba(180,60,10,0.8)', 'rgba(240,140,30,0.9)'];
|
||||
const STAR_COLORS = ['rgba(255,255,255,0.9)', 'rgba(255,240,180,0.85)', 'rgba(180,210,255,0.8)'];
|
||||
|
||||
// ── Spawn helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function spawnSnow(W: number, H: number): Particle {
|
||||
return {
|
||||
x: rand(0, W), y: rand(-H * 0.2, -4),
|
||||
r: rand(1.5, 5),
|
||||
vx: rand(-0.4, 0.4), vy: rand(0.6, 2.0),
|
||||
angle: 0, vAngle: 0,
|
||||
opacity: rand(0.5, 1), vOpacity: 0,
|
||||
color: SNOW_COLORS[randInt(0, SNOW_COLORS.length - 1)],
|
||||
};
|
||||
}
|
||||
|
||||
function spawnSakura(W: number, H: number): Particle {
|
||||
return {
|
||||
x: rand(0, W), y: rand(-H * 0.2, -4),
|
||||
r: rand(3, 7),
|
||||
vx: rand(-0.6, 0.6), vy: rand(0.5, 1.6),
|
||||
angle: rand(0, Math.PI * 2), vAngle: rand(-0.03, 0.03),
|
||||
opacity: rand(0.6, 1), vOpacity: 0,
|
||||
color: SAKURA_COLORS[randInt(0, SAKURA_COLORS.length - 1)],
|
||||
};
|
||||
}
|
||||
|
||||
function spawnFirefly(W: number, H: number): Particle {
|
||||
return {
|
||||
x: rand(0, W), y: rand(H * 0.3, H),
|
||||
r: rand(1.5, 3.5),
|
||||
vx: rand(-0.3, 0.3), vy: rand(-0.8, -0.2),
|
||||
angle: 0, vAngle: 0,
|
||||
opacity: rand(0.2, 0.8), vOpacity: rand(0.008, 0.025) * (Math.random() < 0.5 ? 1 : -1),
|
||||
color: FIREFLY_COLORS[randInt(0, FIREFLY_COLORS.length - 1)],
|
||||
};
|
||||
}
|
||||
|
||||
function spawnLeaf(W: number, H: number): Particle {
|
||||
return {
|
||||
x: rand(0, W), y: rand(-H * 0.2, -4),
|
||||
r: rand(4, 9),
|
||||
vx: rand(-1.2, 1.2), vy: rand(0.8, 2.5),
|
||||
angle: rand(0, Math.PI * 2), vAngle: rand(-0.05, 0.05),
|
||||
opacity: rand(0.6, 1), vOpacity: 0,
|
||||
color: LEAF_COLORS[randInt(0, LEAF_COLORS.length - 1)],
|
||||
};
|
||||
}
|
||||
|
||||
function spawnStar(W: number, H: number): Particle {
|
||||
return {
|
||||
x: rand(0, W), y: rand(0, H),
|
||||
r: rand(0.8, 2.5),
|
||||
vx: 0, vy: 0,
|
||||
angle: 0, vAngle: 0,
|
||||
opacity: rand(0.1, 0.9),
|
||||
vOpacity: rand(0.004, 0.015) * (Math.random() < 0.5 ? 1 : -1),
|
||||
color: STAR_COLORS[randInt(0, STAR_COLORS.length - 1)],
|
||||
twinkleOffset: rand(0, Math.PI * 2),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Draw helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function drawSnow(ctx: CanvasRenderingContext2D, p: Particle) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawSakura(ctx: CanvasRenderingContext2D, p: Particle) {
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.angle);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, p.r * 1.8, p.r, 0, 0, Math.PI * 2);
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawFirefly(ctx: CanvasRenderingContext2D, p: Particle) {
|
||||
// Glow effect: large soft circle + small bright core
|
||||
const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * 4);
|
||||
grd.addColorStop(0, p.color);
|
||||
grd.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r * 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = grd;
|
||||
ctx.globalAlpha = p.opacity * 0.6;
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawLeaf(ctx: CanvasRenderingContext2D, p: Particle) {
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.angle);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -p.r * 1.5);
|
||||
ctx.bezierCurveTo(p.r * 1.2, -p.r * 0.5, p.r * 1.2, p.r * 0.5, 0, p.r * 1.5);
|
||||
ctx.bezierCurveTo(-p.r * 1.2, p.r * 0.5, -p.r * 1.2, -p.r * 0.5, 0, -p.r * 1.5);
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawStar(ctx: CanvasRenderingContext2D, p: Particle, t: number) {
|
||||
const pulse = 0.5 + 0.5 * Math.sin(t * 0.002 + (p.twinkleOffset ?? 0));
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.globalAlpha = p.opacity * pulse;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ── Particle count by mode ────────────────────────────────────────────────
|
||||
|
||||
const COUNT: Record<Mode, number> = {
|
||||
snow: 120, sakura: 60, fireflies: 50, leaves: 45, stars: 150,
|
||||
};
|
||||
|
||||
// ── Main effect ──────────────────────────────────────────────────────────
|
||||
|
||||
$effect(() => {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let W = window.innerWidth;
|
||||
let H = window.innerHeight;
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
|
||||
const onResize = () => {
|
||||
W = window.innerWidth;
|
||||
H = window.innerHeight;
|
||||
canvas!.width = W;
|
||||
canvas!.height = H;
|
||||
// Reseed stars on resize since they're positionally fixed
|
||||
if (mode === 'stars') {
|
||||
particles.length = 0;
|
||||
for (let i = 0; i < COUNT.stars; i++) particles.push(spawnStar(W, H));
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
const n = COUNT[mode];
|
||||
const particles: Particle[] = [];
|
||||
|
||||
// Pre-scatter initial particles across the full height
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p: Particle;
|
||||
switch (mode) {
|
||||
case 'snow': p = spawnSnow(W, H); p.y = rand(0, H); break;
|
||||
case 'sakura': p = spawnSakura(W, H); p.y = rand(0, H); break;
|
||||
case 'fireflies': p = spawnFirefly(W, H); break;
|
||||
case 'leaves': p = spawnLeaf(W, H); p.y = rand(0, H); break;
|
||||
case 'stars': p = spawnStar(W, H); break;
|
||||
}
|
||||
particles.push(p);
|
||||
}
|
||||
|
||||
let t = 0;
|
||||
|
||||
function tick() {
|
||||
ctx!.clearRect(0, 0, W, H);
|
||||
ctx!.save();
|
||||
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const p = particles[i];
|
||||
|
||||
switch (mode) {
|
||||
case 'snow': {
|
||||
// Gentle horizontal sway
|
||||
p.vx = Math.sin(t * 0.001 + p.y * 0.01) * 0.5;
|
||||
p.x += p.vx; p.y += p.vy;
|
||||
if (p.y > H + 10) particles[i] = spawnSnow(W, H);
|
||||
else drawSnow(ctx!, p);
|
||||
break;
|
||||
}
|
||||
case 'sakura': {
|
||||
p.vx = Math.sin(t * 0.0008 + p.y * 0.008) * 0.8;
|
||||
p.x += p.vx; p.y += p.vy;
|
||||
p.angle += p.vAngle;
|
||||
if (p.y > H + 20) particles[i] = spawnSakura(W, H);
|
||||
else drawSakura(ctx!, p);
|
||||
break;
|
||||
}
|
||||
case 'fireflies': {
|
||||
p.x += p.vx + Math.sin(t * 0.002 + i) * 0.3;
|
||||
p.y += p.vy;
|
||||
p.opacity += p.vOpacity;
|
||||
if (p.opacity >= 1) { p.opacity = 1; p.vOpacity *= -1; }
|
||||
if (p.opacity <= 0.1) { p.opacity = 0.1; p.vOpacity *= -1; }
|
||||
if (p.y < -10) particles[i] = spawnFirefly(W, H);
|
||||
else drawFirefly(ctx!, p);
|
||||
break;
|
||||
}
|
||||
case 'leaves': {
|
||||
p.vx = Math.sin(t * 0.001 + p.y * 0.01) * 1.2 + p.vx * 0.02;
|
||||
p.x += p.vx; p.y += p.vy;
|
||||
p.angle += p.vAngle;
|
||||
if (p.y > H + 20) particles[i] = spawnLeaf(W, H);
|
||||
else drawLeaf(ctx!, p);
|
||||
break;
|
||||
}
|
||||
case 'stars': {
|
||||
drawStar(ctx!, p, t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx!.restore();
|
||||
t++;
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Fixed full-viewport overlay, pointer-events-none so all clicks pass through.
|
||||
z-index 40 keeps it below the sticky nav (z-50) but above page content.
|
||||
-->
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
class="fixed inset-0 z-40 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
></canvas>
|
||||
@@ -16,7 +16,7 @@
|
||||
const display = $derived(hovered || rating || 0);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each [1,2,3,4,5] as star}
|
||||
<button
|
||||
@@ -44,10 +44,13 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if avg && count}
|
||||
<span class="text-xs text-(--color-muted) ml-1">{avg} ({count})</span>
|
||||
{:else if avg}
|
||||
<span class="text-xs text-(--color-muted) ml-1">{avg}</span>
|
||||
{#if count > 0}
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-semibold text-(--color-text)">{avg.toFixed(1)}</span>
|
||||
<span class="text-xs text-(--color-muted) leading-none">{count} {count === 1 ? 'rating' : 'ratings'}</span>
|
||||
</div>
|
||||
{:else if !readonly}
|
||||
<span class="text-xs text-(--color-muted)">Rate this book</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -379,6 +379,7 @@ export * from './admin_nav_image_gen.js'
|
||||
export * from './admin_nav_text_gen.js'
|
||||
export * from './admin_nav_catalogue_tools.js'
|
||||
export * from './admin_nav_ai_jobs.js'
|
||||
export * from './admin_nav_notifications.js'
|
||||
export * from './admin_nav_feedback.js'
|
||||
export * from './admin_nav_errors.js'
|
||||
export * from './admin_nav_analytics.js'
|
||||
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_notifications.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_notifications.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_NotificationsInputs */
|
||||
|
||||
const en_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Notifications`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Уведомления`)
|
||||
};
|
||||
|
||||
const id_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Notifikasi`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Notificações`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Notifications`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Notifications" |
|
||||
*
|
||||
* @param {Admin_Nav_NotificationsInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_notifications = /** @type {((inputs?: Admin_Nav_NotificationsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_NotificationsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_notifications(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_notifications(inputs)
|
||||
if (locale === "id") return id_admin_nav_notifications(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_notifications(inputs)
|
||||
return fr_admin_nav_notifications(inputs)
|
||||
});
|
||||
@@ -44,6 +44,9 @@ export interface Book {
|
||||
source_url: string;
|
||||
ranking: number;
|
||||
meta_updated: string;
|
||||
archived?: boolean;
|
||||
visibility?: string;
|
||||
submitted_by?: string;
|
||||
}
|
||||
|
||||
export interface ChapterIdx {
|
||||
@@ -76,6 +79,7 @@ export interface PBUserSettings {
|
||||
font_family?: string;
|
||||
font_size?: number;
|
||||
announce_chapter?: boolean;
|
||||
audio_mode?: string;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
@@ -94,6 +98,8 @@ export interface User {
|
||||
oauth_id?: string;
|
||||
polar_customer_id?: string;
|
||||
polar_subscription_id?: string;
|
||||
notify_new_chapters?: boolean;
|
||||
notify_new_chapters_push?: boolean;
|
||||
}
|
||||
|
||||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||||
@@ -376,7 +382,7 @@ export async function getTrendingBooks(limit = 8): Promise<Book[]> {
|
||||
const key = `books:trending:${limit}`;
|
||||
const cached = await cache.get<Book[]>(key);
|
||||
if (cached) return cached;
|
||||
const books = await listN<Book>('books', limit, 'ranking>0', '+ranking');
|
||||
const books = await listN<Book>('books', limit, 'ranking>0&&visibility="public"', '+ranking');
|
||||
await cache.set(key, books, 15 * 60);
|
||||
return books;
|
||||
}
|
||||
@@ -399,7 +405,8 @@ export async function getRecommendedBooks(
|
||||
const genreFilter = sortedGenres
|
||||
.map((g) => `genres~"${g.replace(/"/g, '')}"`)
|
||||
.join('||');
|
||||
books = await listN<Book>('books', limit * 4, genreFilter, '+ranking');
|
||||
const filter = `visibility="public"&&(${genreFilter})`;
|
||||
books = await listN<Book>('books', limit * 4, filter, '+ranking');
|
||||
await cache.set(key, books, 10 * 60);
|
||||
}
|
||||
return books.filter((b) => !excludeSlugs.has(b.slug)).slice(0, limit);
|
||||
@@ -413,7 +420,7 @@ export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
|
||||
const key = `books:recent:${limit}`;
|
||||
const cached = await cache.get<Book[]>(key);
|
||||
if (cached) return cached;
|
||||
const books = await listN<Book>('books', limit, '', '-meta_updated');
|
||||
const books = await listN<Book>('books', limit, 'visibility="public"', '-meta_updated');
|
||||
await cache.set(key, books, 5 * 60);
|
||||
return books;
|
||||
}
|
||||
@@ -447,9 +454,11 @@ export async function recentlyUpdatedBooks(limit = 8): Promise<Book[]> {
|
||||
if (!slugs.length) return recentlyAddedBooks(limit);
|
||||
|
||||
const books = await getBooksBySlugs(new Set(slugs));
|
||||
// Restore recency order (getBooksBySlugs returns in title sort order)
|
||||
// Restore recency order and filter to public-only books.
|
||||
const bookMap = new Map(books.map((b) => [b.slug, b]));
|
||||
const ordered = slugs.flatMap((s) => (bookMap.has(s) ? [bookMap.get(s)!] : []));
|
||||
const ordered = slugs
|
||||
.flatMap((s) => (bookMap.has(s) ? [bookMap.get(s)!] : []))
|
||||
.filter((b) => b.visibility === 'public');
|
||||
|
||||
await cache.set(key, ordered, 5 * 60);
|
||||
return ordered;
|
||||
@@ -656,11 +665,16 @@ function libraryFilter(sessionId: string, userId?: string): string {
|
||||
|
||||
/** Returns all slugs the user has explicitly saved to their library. */
|
||||
export async function getSavedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
|
||||
const cacheKey = userId ? `saved_slugs:user:${userId}` : `saved_slugs:session:${sessionId}`;
|
||||
const cached = await cache.get<string[]>(cacheKey);
|
||||
if (cached) return new Set(cached);
|
||||
const rows = await listAll<UserLibraryEntry>(
|
||||
'user_library',
|
||||
libraryFilter(sessionId, userId)
|
||||
);
|
||||
return new Set(rows.map((r) => r.slug));
|
||||
const slugs = rows.map((r) => r.slug);
|
||||
await cache.set(cacheKey, slugs, SAVED_SLUGS_TTL);
|
||||
return new Set(slugs);
|
||||
}
|
||||
|
||||
/** Returns whether a specific slug is saved. */
|
||||
@@ -707,7 +721,11 @@ export async function saveBook(
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'saveBook POST failed', { slug, status: res.status, body });
|
||||
return;
|
||||
}
|
||||
// Invalidate saved-slugs cache so the next discover load excludes this book.
|
||||
const savedKey = userId ? `saved_slugs:user:${userId}` : `saved_slugs:session:${sessionId}`;
|
||||
await cache.invalidate(savedKey);
|
||||
}
|
||||
|
||||
/** Remove a book from the user's library. */
|
||||
@@ -1013,7 +1031,7 @@ export async function getSettings(
|
||||
|
||||
export async function saveSettings(
|
||||
sessionId: string,
|
||||
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean },
|
||||
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean; audioMode?: string },
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const existing = await listOne<PBUserSettings & { id: string }>(
|
||||
@@ -1033,6 +1051,7 @@ export async function saveSettings(
|
||||
if (settings.fontFamily !== undefined) payload.font_family = settings.fontFamily;
|
||||
if (settings.fontSize !== undefined) payload.font_size = settings.fontSize;
|
||||
if (settings.announceChapter !== undefined) payload.announce_chapter = settings.announceChapter;
|
||||
if (settings.audioMode !== undefined) payload.audio_mode = settings.audioMode;
|
||||
if (userId) payload.user_id = userId;
|
||||
|
||||
if (existing) {
|
||||
@@ -1162,6 +1181,101 @@ export async function getSlugsWithAudio(): Promise<Set<string>> {
|
||||
return new Set(jobs.map((j) => j.slug));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns books that have at least one completed audio chapter, sorted by
|
||||
* number of narrated chapters descending.
|
||||
* Cached for 5 minutes (same TTL as the catalogue audio badge).
|
||||
*/
|
||||
const AUDIO_BOOKS_CACHE_KEY = 'audio:books_with_count';
|
||||
const AUDIO_BOOKS_CACHE_TTL = 5 * 60;
|
||||
|
||||
export interface AudioBookEntry {
|
||||
book: Book;
|
||||
audioChapters: number;
|
||||
}
|
||||
|
||||
export async function getBooksWithAudioCount(limit = 100): Promise<AudioBookEntry[]> {
|
||||
const cached = await cache.get<AudioBookEntry[]>(AUDIO_BOOKS_CACHE_KEY);
|
||||
if (cached) return cached.slice(0, limit);
|
||||
|
||||
// Count done jobs per slug
|
||||
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', 'slug');
|
||||
const countBySlug = new Map<string, number>();
|
||||
for (const j of jobs) {
|
||||
// audio_jobs can have multiple voice variants for the same chapter — deduplicate
|
||||
// by chapter number so we count chapters, not voice variants.
|
||||
// cache_key format: "slug/chapter/voice"
|
||||
const slug = j.slug;
|
||||
if (!countBySlug.has(slug)) countBySlug.set(slug, 0);
|
||||
// We'll use a Set per slug after this loop instead
|
||||
}
|
||||
// Build slug → Set<chapter> to deduplicate voice variants
|
||||
const chapsBySlug = new Map<string, Set<number>>();
|
||||
for (const j of jobs) {
|
||||
if (!chapsBySlug.has(j.slug)) chapsBySlug.set(j.slug, new Set());
|
||||
chapsBySlug.get(j.slug)!.add(j.chapter);
|
||||
}
|
||||
|
||||
const slugs = [...chapsBySlug.keys()];
|
||||
if (slugs.length === 0) return [];
|
||||
|
||||
const books = await getBooksBySlugs(slugs);
|
||||
const bookMap = new Map(books.map((b) => [b.slug, b]));
|
||||
|
||||
const entries: AudioBookEntry[] = [];
|
||||
for (const [slug, chapters] of chapsBySlug) {
|
||||
const book = bookMap.get(slug);
|
||||
if (!book || book.visibility !== 'public') continue;
|
||||
entries.push({ book, audioChapters: chapters.size });
|
||||
}
|
||||
// Sort by most chapters narrated first
|
||||
entries.sort((a, b) => b.audioChapters - a.audioChapters);
|
||||
|
||||
await cache.set(AUDIO_BOOKS_CACHE_KEY, entries, AUDIO_BOOKS_CACHE_TTL);
|
||||
return entries.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map of chapter number → best available voice for a given slug.
|
||||
* "Best" means: prefer `preferredVoice` if a done job exists for it,
|
||||
* otherwise fall back to any done voice for that chapter.
|
||||
* Result is cached per slug for 60 seconds (audio jobs complete frequently).
|
||||
*/
|
||||
export async function getReadyChaptersForSlug(
|
||||
slug: string,
|
||||
preferredVoice = ''
|
||||
): Promise<Map<number, string>> {
|
||||
const cacheKey = `audio:ready_chapters:${slug}`;
|
||||
const cached = await cache.get<{ chapter: number; voice: string }[]>(cacheKey);
|
||||
const raw = cached ?? await (async () => {
|
||||
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"&&status="done"`);
|
||||
const jobs = await listAll<AudioJob>('audio_jobs', filter, 'chapter');
|
||||
const result: { chapter: number; voice: string }[] = jobs.map((j) => ({
|
||||
chapter: j.chapter,
|
||||
voice: j.voice ?? ''
|
||||
}));
|
||||
await cache.set(cacheKey, result, 60);
|
||||
return result;
|
||||
})();
|
||||
|
||||
// Build chapter → voices map
|
||||
const byChapter = new Map<number, string[]>();
|
||||
for (const { chapter, voice } of raw) {
|
||||
if (!byChapter.has(chapter)) byChapter.set(chapter, []);
|
||||
byChapter.get(chapter)!.push(voice);
|
||||
}
|
||||
|
||||
// Resolve best voice per chapter
|
||||
const result = new Map<number, string>();
|
||||
for (const [chapter, voices] of byChapter) {
|
||||
const best = preferredVoice && voices.includes(preferredVoice)
|
||||
? preferredVoice
|
||||
: voices[0] ?? '';
|
||||
result.set(chapter, best);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Translation jobs ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface TranslationJob {
|
||||
@@ -1317,10 +1431,34 @@ export async function isSessionRevoked(authSessionId: string): Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* List all active sessions for a user.
|
||||
* Returns true for user-agents that are clearly automated tools (curl, scrapers,
|
||||
* debug logins, etc.) that should not appear in the user-facing sessions list.
|
||||
* These sessions still exist in the DB so auth checks continue to work.
|
||||
*/
|
||||
function isBotUserAgent(ua: string): boolean {
|
||||
if (!ua) return false;
|
||||
const lower = ua.toLowerCase();
|
||||
return (
|
||||
lower.startsWith('curl/') ||
|
||||
lower.startsWith('python') ||
|
||||
lower.startsWith('wget/') ||
|
||||
lower.startsWith('go-http-client') ||
|
||||
lower.startsWith('axios/') ||
|
||||
lower.startsWith('node-fetch') ||
|
||||
lower.startsWith('undici') ||
|
||||
lower.startsWith('okhttp') ||
|
||||
lower.startsWith('java/')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all active sessions for a user, excluding non-browser/tool sessions
|
||||
* (curl, debug-login artifacts, scrapers, etc.) from the displayed list.
|
||||
* The records still exist in the DB so auth validity checks are unaffected.
|
||||
*/
|
||||
export async function listUserSessions(userId: string): Promise<UserSession[]> {
|
||||
return listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
|
||||
const all = await listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
|
||||
return all.filter((s) => !isBotUserAgent(s.user_agent) && s.ip !== 'debug');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1339,9 +1477,11 @@ async function pruneStaleUserSessions(
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const toDelete = new Set<string>();
|
||||
|
||||
// Mark stale sessions
|
||||
// Mark stale sessions and debug/tool sessions for deletion
|
||||
for (const s of all) {
|
||||
if (s.last_seen < cutoff) toDelete.add(s.id);
|
||||
if (s.last_seen < cutoff || s.ip === 'debug' || isBotUserAgent(s.user_agent)) {
|
||||
toDelete.add(s.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark excess sessions beyond the cap (oldest first — list is sorted -last_seen)
|
||||
@@ -1383,6 +1523,20 @@ export async function revokeUserSession(recordId: string, userId: string): Promi
|
||||
return del.ok || del.status === 204;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session by its auth session ID (the value stored in the cookie).
|
||||
* Used on logout so the row doesn't linger as a phantom active session.
|
||||
*/
|
||||
export async function deleteSessionByAuthId(authSessionId: string): Promise<void> {
|
||||
const row = await listOne<UserSession>('user_sessions', `session_id="${authSessionId}"`);
|
||||
if (!row) return;
|
||||
const token = await getToken();
|
||||
await fetch(`${PB_URL}/api/collections/user_sessions/records/${row.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all sessions for a user (used on password change etc).
|
||||
*/
|
||||
@@ -1399,6 +1553,56 @@ export async function revokeAllUserSessions(userId: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all data associated with a user account:
|
||||
* - user_settings, user_library, progress, comment_votes, book_ratings,
|
||||
* user_subscriptions, user_sessions, notifications rows owned by the user
|
||||
* - the app_users record itself
|
||||
*
|
||||
* Does NOT delete audio files from MinIO (shared cache) or book comments
|
||||
* (anonymised to preserve discussion threads).
|
||||
*/
|
||||
export async function deleteUserAccount(userId: string, sessionId: string): Promise<void> {
|
||||
const collections = [
|
||||
{ name: 'user_settings', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
|
||||
{ name: 'user_library', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
|
||||
{ name: 'progress', filter: `(user_id="${userId}" || session_id="${sessionId}")` },
|
||||
{ name: 'comment_votes', filter: `user_id="${userId}"` },
|
||||
{ name: 'book_ratings', filter: `user_id="${userId}"` },
|
||||
{ name: 'user_subscriptions', filter: `(follower_id="${userId}" || followee_id="${userId}")` },
|
||||
{ name: 'notifications', filter: `user_id="${userId}"` },
|
||||
{ name: 'user_sessions', filter: `user_id="${userId}"` },
|
||||
];
|
||||
|
||||
const token = await getToken();
|
||||
|
||||
for (const { name, filter } of collections) {
|
||||
try {
|
||||
const rows = await listAll<{ id: string }>(name, filter);
|
||||
await Promise.all(
|
||||
rows.map((r) =>
|
||||
fetch(`${PB_URL}/api/collections/${name}/records/${r.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).catch(() => {})
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
// Best-effort: log and continue so one failure doesn't abort the rest
|
||||
log.warn('pocketbase', `deleteUserAccount: failed to purge ${name}`, { userId });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the user record last
|
||||
const res = await pbDelete(`/api/collections/app_users/records/${userId}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'deleteUserAccount: failed to delete app_users record', { userId, status: res.status, body });
|
||||
throw new Error(`Failed to delete user record (${res.status})`);
|
||||
}
|
||||
log.info('pocketbase', 'deleteUserAccount: account deleted', { userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the avatar_url field for a user record.
|
||||
*/
|
||||
@@ -1415,6 +1619,28 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user's notification preferences (stored on app_users record).
|
||||
*/
|
||||
export async function updateUserNotificationPrefs(
|
||||
userId: string,
|
||||
prefs: {
|
||||
notify_new_chapters?: boolean;
|
||||
notify_new_chapters_push?: boolean;
|
||||
}
|
||||
): 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(prefs)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`updateUserNotificationPrefs failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Comments ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PBBookComment {
|
||||
@@ -1924,7 +2150,7 @@ export async function getSubscriptionFeed(
|
||||
for (const p of allProgressArrays[i]) {
|
||||
if (seen.has(p.slug)) continue;
|
||||
const book = bookMap.get(p.slug);
|
||||
if (!book) continue;
|
||||
if (!book || book.visibility !== 'public') continue;
|
||||
seen.add(p.slug);
|
||||
feed.push({ book, readerUsername: username, updated: p.updated });
|
||||
}
|
||||
@@ -1966,12 +2192,27 @@ function discoveryFilter(sessionId: string, userId?: string): string {
|
||||
return `session_id="${sessionId}"`;
|
||||
}
|
||||
|
||||
/** Cache TTL (seconds) for per-user voted/saved slug sets. Short — changes on every swipe. */
|
||||
const VOTED_SLUGS_TTL = 30;
|
||||
const SAVED_SLUGS_TTL = 60;
|
||||
|
||||
export async function getVotedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
|
||||
const cacheKey = userId ? `discovery_votes:user:${userId}` : `discovery_votes:session:${sessionId}`;
|
||||
const cached = await cache.get<string[]>(cacheKey);
|
||||
if (cached) return new Set(cached);
|
||||
const rows = await listAll<DiscoveryVote>(
|
||||
'discovery_votes',
|
||||
discoveryFilter(sessionId, userId)
|
||||
).catch(() => [] as DiscoveryVote[]);
|
||||
return new Set(rows.map((r) => r.slug));
|
||||
const slugs = rows.map((r) => r.slug);
|
||||
await cache.set(cacheKey, slugs, VOTED_SLUGS_TTL);
|
||||
return new Set(slugs);
|
||||
}
|
||||
|
||||
/** Invalidate the voted-slugs cache entry after a vote is recorded. */
|
||||
async function invalidateVotedSlugsCache(sessionId: string, userId?: string): Promise<void> {
|
||||
const key = userId ? `discovery_votes:user:${userId}` : `discovery_votes:session:${sessionId}`;
|
||||
await cache.invalidate(key);
|
||||
}
|
||||
|
||||
export async function upsertDiscoveryVote(
|
||||
@@ -1994,6 +2235,7 @@ export async function upsertDiscoveryVote(
|
||||
const res = await pbPost('/api/collections/discovery_votes/records', payload);
|
||||
if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote POST failed', { slug, status: res.status });
|
||||
}
|
||||
await invalidateVotedSlugsCache(sessionId, userId);
|
||||
}
|
||||
|
||||
export async function clearDiscoveryVotes(sessionId: string, userId?: string): Promise<void> {
|
||||
@@ -2004,6 +2246,7 @@ export async function clearDiscoveryVotes(sessionId: string, userId?: string): P
|
||||
pbDelete(`/api/collections/discovery_votes/records/${r.id}`).catch(() => {})
|
||||
)
|
||||
);
|
||||
await invalidateVotedSlugsCache(sessionId, userId);
|
||||
}
|
||||
|
||||
// ─── Ratings ──────────────────────────────────────────────────────────────────
|
||||
@@ -2098,13 +2341,18 @@ export async function getBooksForDiscovery(
|
||||
userId?: string,
|
||||
prefs?: DiscoveryPrefs
|
||||
): Promise<Book[]> {
|
||||
const [allBooks, votedSlugs, savedSlugs] = await Promise.all([
|
||||
// Fetch all 4 independent data sources in parallel — previously getAllRatings
|
||||
// ran sequentially after the first group, adding it to the critical path.
|
||||
const [allBooks, votedSlugs, savedSlugs, ratingRows] = await Promise.all([
|
||||
listBooks(),
|
||||
getVotedSlugs(sessionId, userId),
|
||||
getSavedSlugs(sessionId, userId)
|
||||
getSavedSlugs(sessionId, userId),
|
||||
getAllRatings(),
|
||||
]);
|
||||
|
||||
let candidates = allBooks.filter((b) => !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug));
|
||||
let candidates = allBooks.filter(
|
||||
(b) => b.visibility === 'public' && !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug)
|
||||
);
|
||||
|
||||
if (prefs?.genres?.length) {
|
||||
const preferred = new Set(prefs.genres.map((g) => g.toLowerCase()));
|
||||
@@ -2120,10 +2368,7 @@ export async function getBooksForDiscovery(
|
||||
if (sf.length >= 3) candidates = sf;
|
||||
}
|
||||
|
||||
// Fetch avg ratings for candidates, weight top-rated books to surface earlier.
|
||||
// Fetch in one shot for all candidate slugs. Low-rated / unrated books still
|
||||
// appear — they're just pushed further back via a stable sort before shuffle.
|
||||
const ratingRows = await getAllRatings();
|
||||
// Build slug→avg rating map
|
||||
const ratingMap = new Map<string, { sum: number; count: number }>();
|
||||
for (const r of ratingRows) {
|
||||
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
|
||||
@@ -2199,6 +2444,7 @@ export async function undoDiscoveryVote(
|
||||
if (row) {
|
||||
await pbDelete(`/api/collections/discovery_votes/records/${row.id}`).catch(() => {});
|
||||
}
|
||||
await invalidateVotedSlugsCache(sessionId, userId);
|
||||
}
|
||||
|
||||
// ─── User stats ────────────────────────────────────────────────────────────────
|
||||
@@ -2285,6 +2531,90 @@ export async function getUserStats(
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Site Config ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A single singleton record in the `site_config` collection holds global
|
||||
// display settings (seasonal decoration, logo animation, etc.).
|
||||
// The record is lazily created on first write; reads return safe defaults if
|
||||
// the collection/record doesn't exist yet.
|
||||
|
||||
export interface SiteConfig {
|
||||
/** Seasonal decoration particle effect: null = off */
|
||||
decoration: 'snow' | 'sakura' | 'fireflies' | 'leaves' | 'stars' | null;
|
||||
/** Special CSS class applied to the nav logo text */
|
||||
logoAnimation: 'none' | 'glow' | 'rainbow' | 'pulse' | 'shimmer';
|
||||
/** Human-readable label for the current event/season shown in a small badge */
|
||||
eventLabel: string;
|
||||
}
|
||||
|
||||
const SITE_CONFIG_DEFAULTS: SiteConfig = {
|
||||
decoration: null,
|
||||
logoAnimation: 'none',
|
||||
eventLabel: '',
|
||||
};
|
||||
|
||||
// In-memory short cache so every SSR request doesn't hammer PocketBase
|
||||
let _siteConfigCache: { value: SiteConfig; exp: number } | null = null;
|
||||
const SITE_CONFIG_CACHE_TTL = 60_000; // 60 seconds
|
||||
|
||||
export async function getSiteConfig(): Promise<SiteConfig> {
|
||||
if (_siteConfigCache && Date.now() < _siteConfigCache.exp) {
|
||||
return _siteConfigCache.value;
|
||||
}
|
||||
try {
|
||||
const list = await pbGet<{ items: Array<{ id: string } & SiteConfig> }>(
|
||||
'/api/collections/site_config/records?perPage=1'
|
||||
);
|
||||
const row = list.items?.[0];
|
||||
const value: SiteConfig = row
|
||||
? {
|
||||
decoration: row.decoration ?? null,
|
||||
logoAnimation: row.logoAnimation ?? 'none',
|
||||
eventLabel: row.eventLabel ?? '',
|
||||
}
|
||||
: { ...SITE_CONFIG_DEFAULTS };
|
||||
_siteConfigCache = { value, exp: Date.now() + SITE_CONFIG_CACHE_TTL };
|
||||
return value;
|
||||
} catch {
|
||||
// Collection may not exist yet — return defaults silently
|
||||
return { ...SITE_CONFIG_DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSiteConfig(patch: Partial<SiteConfig>): Promise<void> {
|
||||
// Bust cache
|
||||
_siteConfigCache = null;
|
||||
|
||||
// Ensure collection exists and find the singleton record
|
||||
let existingId: string | null = null;
|
||||
try {
|
||||
const list = await pbGet<{ items: Array<{ id: string }> }>(
|
||||
'/api/collections/site_config/records?perPage=1'
|
||||
);
|
||||
existingId = list.items?.[0]?.id ?? null;
|
||||
} catch {
|
||||
// Collection doesn't exist yet — create it via PocketBase API
|
||||
await pbPost('/api/collections', {
|
||||
name: 'site_config',
|
||||
type: 'base',
|
||||
fields: [
|
||||
{ name: 'decoration', type: 'text' },
|
||||
{ name: 'logoAnimation', type: 'text' },
|
||||
{ name: 'eventLabel', type: 'text' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (existingId) {
|
||||
await pbPatch(`/api/collections/site_config/records/${existingId}`, patch);
|
||||
} else {
|
||||
await pbPost('/api/collections/site_config/records', {
|
||||
...SITE_CONFIG_DEFAULTS,
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AI Jobs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const AI_JOBS_CACHE_KEY = 'admin:ai_jobs';
|
||||
|
||||
@@ -15,18 +15,31 @@ import { env } from '$env/dynamic/private';
|
||||
import * as cache from '$lib/server/cache';
|
||||
|
||||
export const BACKEND_URL = env.BACKEND_API_URL ?? 'http://localhost:8080';
|
||||
const ADMIN_TOKEN = env.BACKEND_ADMIN_TOKEN ?? '';
|
||||
|
||||
/**
|
||||
* Fetch a path on the backend, throwing a 502 on network failures.
|
||||
*
|
||||
* The `path` must start with `/` (e.g. `/api/voices`).
|
||||
* Requests to `/api/admin/*` automatically include the Bearer token from
|
||||
* the BACKEND_ADMIN_TOKEN environment variable.
|
||||
*
|
||||
* SvelteKit `error()` exceptions are always re-thrown so callers can
|
||||
* short-circuit correctly inside their own catch blocks.
|
||||
*/
|
||||
export async function backendFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||
let finalInit = init;
|
||||
if (ADMIN_TOKEN && path.startsWith('/api/admin')) {
|
||||
finalInit = {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ADMIN_TOKEN}`,
|
||||
...((init?.headers ?? {}) as Record<string, string>)
|
||||
}
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await fetch(`${BACKEND_URL}${path}`, init);
|
||||
return await fetch(`${BACKEND_URL}${path}`, finalInit);
|
||||
} catch (e) {
|
||||
// Re-throw SvelteKit HTTP errors so they propagate to the framework.
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
@@ -34,6 +47,27 @@ export async function backendFetch(path: string, init?: RequestInit): Promise<Re
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like backendFetch but always attaches the admin bearer token regardless of path.
|
||||
* Use this when an admin user should bypass the visibility filter on public endpoints
|
||||
* (e.g. GET /api/catalogue for the admin catalogue view).
|
||||
*/
|
||||
export async function backendFetchAdmin(path: string, init?: RequestInit): Promise<Response> {
|
||||
const finalInit: RequestInit = {
|
||||
...init,
|
||||
headers: {
|
||||
...(ADMIN_TOKEN ? { Authorization: `Bearer ${ADMIN_TOKEN}` } : {}),
|
||||
...((init?.headers ?? {}) as Record<string, string>)
|
||||
}
|
||||
};
|
||||
try {
|
||||
return await fetch(`${BACKEND_URL}${path}`, finalInit);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cached admin model lists ─────────────────────────────────────────────────
|
||||
|
||||
const MODELS_CACHE_TTL = 10 * 60; // 10 minutes — model lists rarely change
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { getSettings } from '$lib/server/pocketbase';
|
||||
import { getSettings, getSiteConfig } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
// Routes that are accessible without being logged in
|
||||
@@ -17,7 +17,7 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
||||
redirect(302, `/login`);
|
||||
}
|
||||
|
||||
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0, announceChapter: false };
|
||||
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0, announceChapter: false, audioMode: 'stream' };
|
||||
try {
|
||||
const row = await getSettings(locals.sessionId, locals.user?.id);
|
||||
if (row) {
|
||||
@@ -29,7 +29,8 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
||||
locale: row.locale ?? 'en',
|
||||
fontFamily: row.font_family ?? 'system',
|
||||
fontSize: row.font_size || 1.0,
|
||||
announceChapter: row.announce_chapter ?? false
|
||||
announceChapter: row.announce_chapter ?? false,
|
||||
audioMode: row.audio_mode ?? 'stream'
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -59,6 +60,11 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
||||
return {
|
||||
user: locals.user,
|
||||
isPro: locals.isPro,
|
||||
settings
|
||||
settings,
|
||||
siteConfig: await getSiteConfig().catch(() => ({
|
||||
decoration: null as null,
|
||||
logoAnimation: 'none' as const,
|
||||
eventLabel: '',
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
import { locales, getLocale } from '$lib/paraglide/runtime.js';
|
||||
import ListeningMode from '$lib/components/ListeningMode.svelte';
|
||||
import SearchModal from '$lib/components/SearchModal.svelte';
|
||||
import NotificationsModal from '$lib/components/NotificationsModal.svelte';
|
||||
import SeasonalDecoration from '$lib/components/SeasonalDecoration.svelte';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
|
||||
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||
@@ -42,7 +44,28 @@
|
||||
notifications = notifications.map(n => n.id === id ? {...n, read: true} : n);
|
||||
} catch (e) { console.error('mark read:', e); }
|
||||
}
|
||||
async function markAllRead() {
|
||||
if (!data.user) return;
|
||||
try {
|
||||
await fetch('/api/notifications?user_id=' + data.user.id, { method: 'PATCH' });
|
||||
notifications = notifications.map(n => ({ ...n, read: true }));
|
||||
} catch (e) { console.error('mark all read:', e); }
|
||||
}
|
||||
async function dismissNotification(id: string) {
|
||||
try {
|
||||
await fetch('/api/notifications/' + id, { method: 'DELETE' });
|
||||
notifications = notifications.filter(n => n.id !== id);
|
||||
} catch (e) { console.error('dismiss notification:', e); }
|
||||
}
|
||||
async function clearAllNotifications() {
|
||||
if (!data.user) return;
|
||||
try {
|
||||
await fetch('/api/notifications?user_id=' + data.user.id, { method: 'DELETE' });
|
||||
notifications = [];
|
||||
} catch (e) { console.error('clear notifications:', e); }
|
||||
}
|
||||
$effect(() => { if (data.user) loadNotifications(); });
|
||||
$effect(() => { if (notificationsOpen && data.user) loadNotifications(); });
|
||||
const unreadCount = $derived(notifications.filter(n => !n.read).length);
|
||||
|
||||
// Close search on navigation
|
||||
@@ -72,6 +95,20 @@
|
||||
let listeningModeOpen = $state(false);
|
||||
let listeningModeChapters = $state(false);
|
||||
|
||||
// ── Site config (seasonal decoration + logo animation) ──────────────────
|
||||
// svelte-ignore state_referenced_locally
|
||||
let siteDecoration = $state(data.siteConfig?.decoration ?? null);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let siteLogoAnim = $state(data.siteConfig?.logoAnimation ?? 'none');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let siteEventLabel = $state(data.siteConfig?.eventLabel ?? '');
|
||||
// Refresh when invalidateAll() re-runs layout load (e.g. after admin saves)
|
||||
$effect(() => {
|
||||
siteDecoration = data.siteConfig?.decoration ?? null;
|
||||
siteLogoAnim = data.siteConfig?.logoAnimation ?? 'none';
|
||||
siteEventLabel = data.siteConfig?.eventLabel ?? '';
|
||||
});
|
||||
|
||||
// Build time formatted in the user's local timezone (populated on mount so
|
||||
// SSR and CSR don't produce a mismatch — SSR renders nothing, hydration fills it in).
|
||||
let buildTimeLocal = $state('');
|
||||
@@ -133,6 +170,7 @@
|
||||
audioStore.voice = data.settings.voice;
|
||||
audioStore.speed = data.settings.speed;
|
||||
audioStore.announceChapter = data.settings.announceChapter ?? false;
|
||||
audioStore.audioMode = (data.settings.audioMode === 'generate' ? 'generate' : 'stream');
|
||||
}
|
||||
// Always sync theme + font (profile page calls invalidateAll after saving)
|
||||
currentTheme = data.settings.theme ?? 'amber';
|
||||
@@ -155,6 +193,7 @@
|
||||
const fontFamily = currentFontFamily;
|
||||
const fontSize = currentFontSize;
|
||||
const announceChapter = audioStore.announceChapter;
|
||||
const audioMode = audioStore.audioMode;
|
||||
|
||||
// Skip saving until settings have been applied from the server AND
|
||||
// at least one user-driven change has occurred after that.
|
||||
@@ -165,7 +204,7 @@
|
||||
fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize, announceChapter })
|
||||
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize, announceChapter, audioMode })
|
||||
}).catch(() => {});
|
||||
}, 800) as unknown as number;
|
||||
});
|
||||
@@ -489,7 +528,7 @@
|
||||
style="display:none"
|
||||
></audio>
|
||||
|
||||
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active}>
|
||||
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active && !audioStore.suppressMiniBar}>
|
||||
<!-- Navigation progress bar — shown while SSR is running for any page transition -->
|
||||
{#if navigating}
|
||||
<div class="fixed top-0 left-0 right-0 z-[100] h-1 bg-(--color-surface-2)">
|
||||
@@ -498,8 +537,18 @@
|
||||
{/if}
|
||||
<header class="border-b border-(--color-border) bg-(--color-surface) sticky top-0 z-50">
|
||||
<nav class="max-w-6xl mx-auto px-4 h-14 flex items-center gap-6">
|
||||
<a href="/" class="text-(--color-brand) font-bold text-lg tracking-tight hover:text-(--color-brand-dim) shrink-0">
|
||||
<a href="/" class="text-(--color-brand) font-bold text-lg tracking-tight hover:text-(--color-brand-dim) shrink-0 flex items-center gap-1.5
|
||||
{siteLogoAnim === 'glow' ? 'logo-anim-glow' : ''}
|
||||
{siteLogoAnim === 'shimmer' ? 'logo-anim-shimmer' : ''}
|
||||
{siteLogoAnim === 'pulse' ? 'logo-anim-pulse' : ''}
|
||||
{siteLogoAnim === 'rainbow' ? 'logo-anim-rainbow' : ''}
|
||||
">
|
||||
libnovel
|
||||
{#if siteEventLabel}
|
||||
<span class="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 leading-none tracking-wide">
|
||||
{siteEventLabel}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
{#if page.data.book?.title && /\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
|
||||
@@ -537,6 +586,14 @@
|
||||
>
|
||||
{m.nav_catalogue()}
|
||||
</a>
|
||||
{#if data.user}
|
||||
<a
|
||||
href="/submit"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/submit') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Publish
|
||||
</a>
|
||||
{/if}
|
||||
{#if !data.isPro}
|
||||
<a
|
||||
href="/subscribe"
|
||||
@@ -547,27 +604,25 @@
|
||||
</a>
|
||||
{/if}
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<!-- Universal search button (hidden on chapter/reader pages) -->
|
||||
{#if !/\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; notificationsOpen = false; }}
|
||||
title="Search (/ or ⌘K)"
|
||||
aria-label="Search books"
|
||||
class="flex items-center justify-center w-8 h-8 rounded transition-colors text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Universal search button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; notificationsOpen = false; }}
|
||||
title="Search (/ or ⌘K)"
|
||||
aria-label="Search books"
|
||||
class="flex items-center justify-center w-8 h-8 rounded transition-colors text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Notifications bell -->
|
||||
{#if data.user?.role === 'admin'}
|
||||
<div class="relative">
|
||||
<!-- Notifications bell -->
|
||||
{#if data.user}
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { notificationsOpen = !notificationsOpen; searchOpen = false; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; }}
|
||||
onclick={() => { notificationsOpen = !notificationsOpen; searchOpen = false; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; }}
|
||||
title="Notifications"
|
||||
class="flex items-center justify-center w-8 h-8 rounded transition-colors {notificationsOpen ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)'} relative"
|
||||
>
|
||||
@@ -578,24 +633,6 @@
|
||||
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if notificationsOpen}
|
||||
<div class="absolute right-0 top-full mt-1 w-80 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl z-50 max-h-96 overflow-y-auto">
|
||||
{#if notifications.length === 0}
|
||||
<div class="p-4 text-center text-(--color-muted) text-sm">No notifications</div>
|
||||
{:else}
|
||||
{#each notifications as n}
|
||||
<a
|
||||
href={n.link || '/admin/import'}
|
||||
onclick={() => { markRead(n.id); notificationsOpen = false; }}
|
||||
class="block p-3 border-b border-(--color-border)/50 hover:bg-(--color-surface-3) {n.read ? 'opacity-60' : ''}"
|
||||
>
|
||||
<div class="text-sm font-medium">{n.title}</div>
|
||||
<div class="text-xs text-(--color-muted) mt-0.5">{n.message}</div>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Theme dropdown (desktop) -->
|
||||
@@ -740,14 +777,15 @@
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Click-outside overlay for dropdowns -->
|
||||
{#if langMenuOpen || userMenuOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
<!-- Click-outside overlay for dropdowns -->
|
||||
{#if langMenuOpen || userMenuOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<div class="ml-auto">
|
||||
<a
|
||||
@@ -791,6 +829,15 @@
|
||||
>
|
||||
{m.nav_catalogue()}
|
||||
</a>
|
||||
{#if data.user}
|
||||
<a
|
||||
href="/submit"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/submit') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
Publish
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
href="https://feedback.libnovel.cc"
|
||||
target="_blank"
|
||||
@@ -880,6 +927,17 @@
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Backdrop for mobile hamburger menu — outside <header> so the blur
|
||||
only affects page content below, not the drawer items themselves -->
|
||||
{#if menuOpen}
|
||||
<div
|
||||
class="fixed top-14 inset-x-0 bottom-0 z-40 sm:hidden"
|
||||
style="background: rgba(0,0,0,0.6); backdrop-filter: blur(4px);"
|
||||
onpointerdown={() => { menuOpen = false; }}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<main class="flex-1 max-w-6xl mx-auto w-full px-4 py-8">
|
||||
{#key page.url.pathname + page.url.search}
|
||||
<div in:fade={{ duration: 180, delay: 60 }} out:fade={{ duration: 100 }}>
|
||||
@@ -954,7 +1012,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Persistent mini-player bar ─────────────────────────────────────────── -->
|
||||
{#if audioStore.active}
|
||||
{#if audioStore.active && !audioStore.suppressMiniBar}
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface) border-t border-(--color-border) shadow-2xl">
|
||||
|
||||
<!-- Generation progress bar (sits at very top of the bar) -->
|
||||
@@ -975,6 +1033,7 @@
|
||||
max={audioStore.duration || 0}
|
||||
value={audioStore.currentTime}
|
||||
oninput={seek}
|
||||
onchange={seek}
|
||||
class="w-full h-1 accent-[--color-brand] cursor-pointer block"
|
||||
style="margin: 0; border-radius: 0; accent-color: var(--color-brand);"
|
||||
/>
|
||||
@@ -1120,12 +1179,10 @@
|
||||
<!-- Listening mode — mounted at root level, independent of audioStore.active,
|
||||
so closing/pausing audio never tears it down and loses context. -->
|
||||
{#if listeningModeOpen}
|
||||
<div transition:fly={{ y: '100%', duration: 320, opacity: 1 }} style="pointer-events: none;">
|
||||
<ListeningMode
|
||||
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
|
||||
openChapters={listeningModeChapters}
|
||||
/>
|
||||
</div>
|
||||
<ListeningMode
|
||||
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
|
||||
openChapters={listeningModeChapters}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Universal search modal — shown from anywhere except focus mode / listening mode -->
|
||||
@@ -1133,12 +1190,24 @@
|
||||
<SearchModal onclose={() => { searchOpen = false; }} />
|
||||
{/if}
|
||||
|
||||
<!-- Notifications modal — full-screen, shown for all logged-in users -->
|
||||
{#if notificationsOpen && data.user}
|
||||
<NotificationsModal
|
||||
notifications={notifications}
|
||||
userId={data.user.id}
|
||||
isAdmin={data.user.role === 'admin'}
|
||||
onclose={() => { notificationsOpen = false; }}
|
||||
onMarkRead={markRead}
|
||||
onMarkAllRead={markAllRead}
|
||||
onDismiss={dismissNotification}
|
||||
onClearAll={clearAllNotifications}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<svelte:window onkeydown={(e) => {
|
||||
// Don't intercept when typing in an input/textarea
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable) return;
|
||||
// Don't open on chapter reader pages
|
||||
if (/\/books\/[^/]+\/chapters\//.test(page.url.pathname)) return;
|
||||
if (searchOpen) return;
|
||||
// `/` key or Cmd/Ctrl+K
|
||||
if (e.key === '/' || ((e.metaKey || e.ctrlKey) && e.key === 'k')) {
|
||||
@@ -1146,3 +1215,13 @@
|
||||
searchOpen = true;
|
||||
}
|
||||
}} />
|
||||
|
||||
<!-- Seasonal decoration overlay — rendered above page content, below nav -->
|
||||
{#if siteDecoration}
|
||||
<SeasonalDecoration mode={siteDecoration} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Logo animation keyframes are defined globally in app.css */
|
||||
/* This block intentionally left minimal — all logo-anim-* classes live in app.css */
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
getHomeStats,
|
||||
getSubscriptionFeed,
|
||||
getTrendingBooks,
|
||||
getRecommendedBooks
|
||||
getRecommendedBooks,
|
||||
getBooksWithAudioCount
|
||||
} from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import type { Book, Progress } from '$lib/server/pocketbase';
|
||||
@@ -87,8 +88,8 @@ 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);
|
||||
|
||||
// Fetch trending, recommendations, and subscription feed in parallel
|
||||
const [trendingBooks, recommendedBooks, subscriptionFeed] = await Promise.all([
|
||||
// Fetch trending, recommendations, subscription feed, and audio books in parallel
|
||||
const [trendingBooks, recommendedBooks, subscriptionFeed, audioBooks] = await Promise.all([
|
||||
getTrendingBooks(8).catch(() => [] as Book[]),
|
||||
topGenres.length > 0
|
||||
? getRecommendedBooks(topGenres, inProgressSlugs, 8).catch(() => [] as Book[])
|
||||
@@ -98,12 +99,18 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
log.error('home', 'failed to load subscription feed', { err: String(e) });
|
||||
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
|
||||
})
|
||||
: Promise.resolve([])
|
||||
: Promise.resolve([]),
|
||||
getBooksWithAudioCount(20).catch(() => [])
|
||||
]);
|
||||
|
||||
// Strip books the user is already reading from trending (redundant)
|
||||
const trendingFiltered = trendingBooks.filter((b) => !inProgressSlugs.has(b.slug));
|
||||
|
||||
// Strip already-reading books from audio shelf; cap at 8
|
||||
const readyToListen = audioBooks
|
||||
.filter((e) => !inProgressSlugs.has(e.book.slug))
|
||||
.slice(0, 8);
|
||||
|
||||
return {
|
||||
continueInProgress,
|
||||
continueCompleted,
|
||||
@@ -111,6 +118,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
subscriptionFeed,
|
||||
trendingBooks: trendingFiltered,
|
||||
recommendedBooks,
|
||||
readyToListen,
|
||||
topGenre: topGenres[0] ?? null,
|
||||
stats: {
|
||||
...stats,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// ── Section visibility ────────────────────────────────────────────────────────
|
||||
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read';
|
||||
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read' | 'ready-to-listen';
|
||||
const SECTIONS_KEY = 'home_sections_v1';
|
||||
|
||||
function loadHidden(): Set<SectionId> {
|
||||
@@ -40,6 +40,7 @@
|
||||
'from-following': 'From Following',
|
||||
'trending': 'Trending Now',
|
||||
'because-you-read': data.topGenre ? `Because you read ${data.topGenre}` : 'Recommendations',
|
||||
'ready-to-listen': 'Ready to Listen',
|
||||
});
|
||||
|
||||
const hiddenList = $derived(
|
||||
@@ -135,18 +136,34 @@
|
||||
|
||||
<!-- ── Hero carousel ──────────────────────────────────────────────────────────── -->
|
||||
{#if heroBook}
|
||||
{@const stackBooks = heroBooks.length > 1
|
||||
? Array.from({ length: Math.min(heroBooks.length - 1, 3) }, (_, i) =>
|
||||
heroBooks[(heroIndex + 1 + i) % heroBooks.length])
|
||||
: []}
|
||||
<section class="mb-6">
|
||||
<div class="relative">
|
||||
<!-- Card — swipe to navigate -->
|
||||
<!-- Outer flex row: front card + queued book spines (sm+ only) -->
|
||||
<div class="relative flex items-stretch gap-0">
|
||||
|
||||
<!-- Front card — swipe to navigate -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all"
|
||||
class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all z-[2] flex-1 min-w-0"
|
||||
ontouchstart={onSwipeStart}
|
||||
ontouchend={onSwipeEnd}
|
||||
>
|
||||
<!-- Cover -->
|
||||
<!-- Atmospheric blurred cover background (z-0, content is z-[1]) -->
|
||||
{#if heroBook.book.cover}
|
||||
{#key heroIndex}
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center scale-110 animate-fade-in pointer-events-none z-0"
|
||||
style="background-image: url('{heroBook.book.cover}'); filter: blur(40px) saturate(1.3); opacity: 0.18;"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/key}
|
||||
{/if}
|
||||
<!-- Cover — drives card height via aspect-[2/3] -->
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden block">
|
||||
class="w-32 sm:w-44 shrink-0 self-stretch overflow-hidden block relative z-[1]">
|
||||
{#if heroBook.book.cover}
|
||||
{#key heroIndex}
|
||||
<img src={heroBook.book.cover} alt={heroBook.book.title}
|
||||
@@ -161,35 +178,35 @@
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0 flex-1">
|
||||
<div>
|
||||
<!-- Info — fixed height matching cover, overflow hidden so text never expands the card -->
|
||||
<div class="relative z-[1] flex flex-col justify-between p-5 sm:p-7 min-w-0 flex-1 overflow-hidden
|
||||
h-[calc(128px*3/2)] sm:h-[calc(176px*3/2)]">
|
||||
<div class="min-h-0 overflow-hidden">
|
||||
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
|
||||
{#if heroBook.book.author}
|
||||
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
|
||||
<p class="text-sm text-(--color-muted) truncate">{heroBook.book.author}</p>
|
||||
{/if}
|
||||
{#if heroBook.book.summary}
|
||||
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.summary}</p>
|
||||
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-3 max-w-prose">{heroBook.book.summary}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-4 flex-wrap">
|
||||
<div class="flex items-center gap-3 mt-4 flex-wrap shrink-0">
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(heroBook!.book.slug, heroBook!.chapter)}
|
||||
<a
|
||||
href="/books/{heroBook.book.slug}"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 font-semibold text-sm transition-colors"
|
||||
title="Listen to narration"
|
||||
title="Book info"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 9a3 3 0 114 2.83V17m0 0a2 2 0 11-4 0m4 0H9m9-8a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20A10 10 0 0012 2z"/>
|
||||
</svg>
|
||||
Listen
|
||||
</button>
|
||||
Info
|
||||
</a>
|
||||
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
@@ -197,36 +214,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dot indicators -->
|
||||
{#if heroBooks.length > 1}
|
||||
<div class="flex items-center justify-center gap-2 mt-2.5">
|
||||
{#each heroBooks as _, i}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => heroDot(i)}
|
||||
aria-label="Go to book {i + 1}"
|
||||
>
|
||||
<span class="block rounded-full transition-all duration-300 {i === heroIndex
|
||||
? 'w-4 h-1.5 bg-(--color-brand)'
|
||||
: 'w-1.5 h-1.5 bg-(--color-border) hover:bg-(--color-muted)'}"></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Queued book spines — visible sm+ only, peek to the right of the front card -->
|
||||
{#each stackBooks as stackBook, i}
|
||||
{@const opacity = i === 0 ? 'opacity-70' : 'opacity-40'}
|
||||
{@const width = i === 0 ? 'sm:w-10' : 'sm:w-7'}
|
||||
<a
|
||||
href="/books/{stackBook.book.slug}/chapters/{stackBook.chapter}"
|
||||
class="hidden sm:block shrink-0 {width} rounded-r-xl overflow-hidden border border-l-0 border-(--color-border) {opacity} hover:opacity-90 transition-opacity"
|
||||
aria-label={stackBook.book.title}
|
||||
tabindex="-1"
|
||||
>
|
||||
{#if stackBook.book.cover}
|
||||
<img src={stackBook.book.cover} alt="" aria-hidden="true"
|
||||
class="w-full h-full object-cover object-left" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3)"></div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Dot indicators -->
|
||||
{#if heroBooks.length > 1}
|
||||
<div class="flex items-center justify-center gap-2 mt-2.5">
|
||||
{#each heroBooks as _, i}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => heroDot(i)}
|
||||
aria-label="Go to book {i + 1}"
|
||||
>
|
||||
<span class="block rounded-full transition-all duration-300 {i === heroIndex
|
||||
? 'w-4 h-1.5 bg-(--color-brand)'
|
||||
: 'w-1.5 h-1.5 bg-(--color-border) hover:bg-(--color-muted)'}"></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Streak widget ───────────────────────────────────────────────────────────── -->
|
||||
{#if streak > 0}
|
||||
<div class="mb-6 flex items-center gap-3 flex-wrap text-sm">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
||||
<span class="font-semibold text-(--color-text)">{streak}</span>
|
||||
<span class="text-(--color-muted)">day{streak !== 1 ? 's' : ''} reading</span>
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand)/10 border border-(--color-brand)/30 text-(--color-brand) font-semibold">
|
||||
<svg class="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13.5 0.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>
|
||||
</svg>
|
||||
{streak} day{streak !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{#if data.stats.booksInProgress > 0}
|
||||
<span class="text-(--color-muted)">
|
||||
<span class="font-semibold text-(--color-text)">{data.stats.booksInProgress}</span> {data.stats.booksInProgress === 1 ? 'book' : 'books'} in progress
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-muted)">
|
||||
<span class="font-semibold text-(--color-text)">{data.stats.booksInProgress}</span>
|
||||
{data.stats.booksInProgress === 1 ? 'book' : 'books'} in progress
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -236,32 +276,39 @@
|
||||
{#if shelfBooks.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
|
||||
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each shelfBooks as { book, chapter }}
|
||||
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-32 sm:w-36">
|
||||
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
|
||||
<a href="/books/{book.slug}/chapters/{chapter}" class="block">
|
||||
<div class="aspect-[2/3] 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 bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" 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>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Chapter badge -->
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
|
||||
{m.home_chapter_badge({ n: String(chapter) })}
|
||||
</span>
|
||||
<!-- Reading progress bar -->
|
||||
{#if book.total_chapters > 0}
|
||||
{@const pct = Math.min(100, Math.round((chapter / book.total_chapters) * 100))}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
|
||||
<div class="h-full bg-(--color-brand) transition-all" style="width: {pct}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
<!-- Listen button (hover overlay) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(book.slug, chapter)}
|
||||
class="absolute bottom-8 left-1.5 w-7 h-7 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
class="absolute bottom-9 left-1.5 w-7 h-7 rounded-full bg-black/60 text-white flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
|
||||
title="Listen"
|
||||
aria-label="Listen to chapter {chapter}"
|
||||
>
|
||||
@@ -269,6 +316,9 @@
|
||||
</button>
|
||||
<a href="/books/{book.slug}/chapters/{chapter}" class="p-2 block">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-(--color-muted) truncate mt-0.5">{book.author}</p>
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -280,7 +330,7 @@
|
||||
{#if data.continueCompleted.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">Completed</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">Completed</h2>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each data.continueCompleted as { book, chapter }}
|
||||
@@ -291,7 +341,7 @@
|
||||
<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 bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" 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>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="absolute top-1.5 right-1.5 text-xs bg-green-600/90 text-white font-bold px-1.5 py-0.5 rounded">Done</span>
|
||||
@@ -308,11 +358,74 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Ready to Listen shelf ──────────────────────────────────────────────────── -->
|
||||
{#if data.readyToListen.length > 0 && !hidden.has('ready-to-listen')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-lg font-bold text-(--color-text)">Ready to Listen</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/listen" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
|
||||
<button type="button" onclick={() => hide('ready-to-listen')} title="Hide section"
|
||||
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each data.readyToListen as { book, audioChapters }}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
|
||||
<a href="/books/{book.slug}" class="block">
|
||||
<div class="aspect-[2/3] 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 bg-(--color-surface-3) flex items-center justify-center">
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Headphones badge -->
|
||||
<span class="absolute bottom-1.5 left-1.5 inline-flex items-center gap-1 text-xs bg-(--color-brand)/90 text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M12 3a9 9 0 00-9 9v5a3 3 0 003 3h1a1 1 0 001-1v-4a1 1 0 00-1-1H5v-2a7 7 0 0114 0v2h-2a1 1 0 00-1 1v4a1 1 0 001 1h1a3 3 0 003-3v-5a9 9 0 00-9-9z"/></svg>
|
||||
{audioChapters} ch
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
<div class="p-2 flex flex-col gap-1 flex-1">
|
||||
<a href="/books/{book.slug}" class="block">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
</a>
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-auto pt-0.5">
|
||||
{#each genres.slice(0, 2) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Listen Ch.1 button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(book.slug, 1)}
|
||||
class="mx-2 mb-2 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md bg-(--color-brand)/15 hover:bg-(--color-brand)/30 text-(--color-brand) text-xs font-semibold transition-colors"
|
||||
aria-label="Listen from chapter 1"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
Listen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Genre discovery strip ─────────────────────────────────────────────────── -->
|
||||
{#if !hidden.has('browse-genre')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">Browse by genre</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">Browse by genre</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
<button type="button" onclick={() => hide('browse-genre')} title="Hide section"
|
||||
@@ -325,8 +438,11 @@
|
||||
</div>
|
||||
<div class="flex gap-2 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4">
|
||||
{#each GENRES as genre}
|
||||
{@const isTop = data.topGenre && genre.toLowerCase() === data.topGenre.toLowerCase()}
|
||||
<a href="/catalogue?genre={encodeURIComponent(genre)}"
|
||||
class="shrink-0 px-3.5 py-1.5 rounded-full border border-(--color-border) bg-(--color-surface-2) text-sm text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors whitespace-nowrap">
|
||||
class="shrink-0 px-3.5 py-1.5 rounded-full border text-sm transition-colors whitespace-nowrap {isTop
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand) font-semibold'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text) hover:bg-(--color-surface-3)'}">
|
||||
{genre}
|
||||
</a>
|
||||
{/each}
|
||||
@@ -338,7 +454,7 @@
|
||||
{#if data.trendingBooks.length > 0 && !hidden.has('trending')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">Trending Now</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">Trending Now</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
<button type="button" onclick={() => hide('trending')} title="Hide section"
|
||||
@@ -359,7 +475,7 @@
|
||||
<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 bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" 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>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="absolute top-1.5 left-1.5 text-xs bg-(--color-brand)/80 text-(--color-surface) font-bold px-1.5 py-0.5 rounded">#{book.ranking}</span>
|
||||
@@ -387,15 +503,18 @@
|
||||
{#if data.recommendedBooks.length > 0 && data.topGenre && !hidden.has('because-you-read')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">
|
||||
Because you read <span class="text-(--color-brand)">{data.topGenre}</span>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">
|
||||
Because you read <span class="text-(--color-brand)">{data.topGenre ? data.topGenre.charAt(0).toUpperCase() + data.topGenre.slice(1) : ''}</span>
|
||||
</h2>
|
||||
<button type="button" onclick={() => hide('because-you-read')} title="Hide section"
|
||||
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/catalogue?genre={encodeURIComponent(data.topGenre ?? '')}" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
|
||||
<button type="button" onclick={() => hide('because-you-read')} title="Hide section"
|
||||
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each data.recommendedBooks as book}
|
||||
@@ -407,7 +526,7 @@
|
||||
<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 bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" 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>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -434,7 +553,7 @@
|
||||
{#if dedupedRecent.length > 0 && !hidden.has('recently-updated')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
<button type="button" onclick={() => hide('recently-updated')} title="Hide section"
|
||||
@@ -455,7 +574,7 @@
|
||||
<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 bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" 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>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if count > 1}
|
||||
@@ -487,7 +606,7 @@
|
||||
{#if data.subscriptionFeed.length > 0 && !hidden.has('from-following')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">{m.home_from_following()}</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_from_following()}</h2>
|
||||
<button type="button" onclick={() => hide('from-following')} title="Hide section"
|
||||
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -504,7 +623,7 @@
|
||||
<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 bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" 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>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { listAIJobs } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
if (locals.user?.role !== 'admin') {
|
||||
redirect(302, '/');
|
||||
}
|
||||
const jobs = await listAIJobs().catch((e) => {
|
||||
log.warn('admin/layout', 'failed to load ai jobs for sidebar badge', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
const runningAiJobs = jobs.filter((j) => j.status === 'running' || j.status === 'pending').length;
|
||||
return { runningAiJobs };
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
const internalLinks = [
|
||||
{
|
||||
@@ -38,6 +39,11 @@
|
||||
label: () => m.admin_nav_ai_jobs(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/notifications',
|
||||
label: () => m.admin_nav_notifications(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/catalogue-tools',
|
||||
label: () => m.admin_nav_catalogue_tools(),
|
||||
@@ -47,6 +53,11 @@
|
||||
href: '/admin/changelog',
|
||||
label: () => m.admin_nav_changelog(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/site-theme',
|
||||
label: () => 'Site Theme',
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />`
|
||||
}
|
||||
];
|
||||
|
||||
@@ -95,8 +106,9 @@
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
data: LayoutData;
|
||||
}
|
||||
let { children }: Props = $props();
|
||||
let { children, data }: Props = $props();
|
||||
|
||||
let sidebarOpen = $state(false);
|
||||
</script>
|
||||
@@ -126,6 +138,7 @@
|
||||
<nav class="flex flex-col gap-0.5">
|
||||
{#each internalLinks as link}
|
||||
{@const active = page.url.pathname.startsWith(link.href)}
|
||||
{@const isAiJobs = link.href === '/admin/ai-jobs'}
|
||||
<a
|
||||
href={link.href}
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
@@ -137,7 +150,12 @@
|
||||
<svg class="w-3.5 h-3.5 shrink-0 {active ? 'text-(--color-brand)' : 'opacity-50'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{@html link.icon}
|
||||
</svg>
|
||||
{link.label()}
|
||||
<span class="flex-1">{link.label()}</span>
|
||||
{#if isAiJobs && data.runningAiJobs > 0}
|
||||
<span class="text-[10px] font-bold tabular-nums px-1.5 py-0.5 rounded-full bg-(--color-brand) text-black leading-none">
|
||||
{data.runningAiJobs}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
@@ -6,8 +6,7 @@ export type { AIJob };
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
// Stream jobs so navigation is instant; list populates a moment later.
|
||||
const jobs = listAIJobs().catch((e): AIJob[] => {
|
||||
const jobs = await listAIJobs().catch((e): AIJob[] => {
|
||||
log.warn('admin/ai-jobs', 'failed to load ai jobs', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
let jobs = $state<AIJob[]>([]);
|
||||
|
||||
// Resolve streamed promise on load and on server reloads (invalidateAll)
|
||||
// data.jobs is a plain AIJob[] (resolved on server); re-sync on invalidateAll
|
||||
$effect(() => {
|
||||
data.jobs.then((resolved) => { jobs = resolved; });
|
||||
jobs = data.jobs;
|
||||
});
|
||||
|
||||
// ── Live-poll while any job is in-flight ─────────────────────────────────────
|
||||
@@ -57,6 +57,7 @@
|
||||
// ── Cancel ────────────────────────────────────────────────────────────────────
|
||||
let cancellingIds = $state(new Set<string>());
|
||||
let cancelErrors: Record<string, string> = $state({});
|
||||
let cancellingAll = $state(false);
|
||||
|
||||
async function cancelJob(id: string) {
|
||||
if (cancellingIds.has(id)) return;
|
||||
@@ -77,6 +78,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelAllRunning() {
|
||||
if (cancellingAll) return;
|
||||
cancellingAll = true;
|
||||
const inFlight = jobs.filter((j) => j.status === 'running' || j.status === 'pending');
|
||||
await Promise.all(inFlight.map((j) => cancelJob(j.id)));
|
||||
cancellingAll = false;
|
||||
}
|
||||
|
||||
// ── Review & Apply (chapter-names jobs) ──────────────────────────────────────
|
||||
|
||||
interface ProposedTitle {
|
||||
@@ -155,22 +164,24 @@
|
||||
};
|
||||
review = r;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
let payload: { pattern?: string; slug?: string; results?: ProposedTitle[] } = {};
|
||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||
let payload: { pattern?: string; slug?: string; results?: ProposedTitle[] } = {};
|
||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||
|
||||
r.pattern = payload.pattern ?? '';
|
||||
r.titles = (payload.results ?? []).map((t: ProposedTitle) => ({ ...t }));
|
||||
r.loading = false;
|
||||
} catch (e) {
|
||||
r.loading = false;
|
||||
r.error = String(e);
|
||||
}
|
||||
} else if (job.kind === 'image-gen') {
|
||||
review = {
|
||||
...r,
|
||||
pattern: payload.pattern ?? '',
|
||||
titles: (payload.results ?? []).map((t: ProposedTitle) => ({ ...t })),
|
||||
loading: false
|
||||
};
|
||||
} catch (e) {
|
||||
review = { ...r, loading: false, error: String(e) };
|
||||
}
|
||||
} else if (job.kind === 'image-gen') {
|
||||
const r: ImageGenReview = {
|
||||
kind: 'image-gen',
|
||||
jobId: job.id,
|
||||
@@ -189,38 +200,40 @@
|
||||
};
|
||||
review = r;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
let payload: {
|
||||
prompt?: string;
|
||||
type?: string;
|
||||
chapter?: number;
|
||||
content_type?: string;
|
||||
image_b64?: string;
|
||||
bytes?: number;
|
||||
} = {};
|
||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||
let payload: {
|
||||
prompt?: string;
|
||||
type?: string;
|
||||
chapter?: number;
|
||||
content_type?: string;
|
||||
image_b64?: string;
|
||||
bytes?: number;
|
||||
} = {};
|
||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||
|
||||
if (!payload.image_b64) {
|
||||
r.error = 'No image in job payload.';
|
||||
r.loading = false;
|
||||
return;
|
||||
}
|
||||
r.imageType = payload.type ?? 'cover';
|
||||
r.chapter = payload.chapter ?? 0;
|
||||
r.prompt = payload.prompt ?? '';
|
||||
r.contentType = payload.content_type ?? 'image/png';
|
||||
r.bytes = payload.bytes ?? 0;
|
||||
r.imageSrc = `data:${r.contentType};base64,${payload.image_b64}`;
|
||||
r.loading = false;
|
||||
} catch (e) {
|
||||
r.loading = false;
|
||||
r.error = String(e);
|
||||
if (!payload.image_b64) {
|
||||
review = { ...r, error: 'No image in job payload.', loading: false };
|
||||
return;
|
||||
}
|
||||
} else if (job.kind === 'description') {
|
||||
const contentType = payload.content_type ?? 'image/png';
|
||||
review = {
|
||||
...r,
|
||||
imageType: payload.type ?? 'cover',
|
||||
chapter: payload.chapter ?? 0,
|
||||
prompt: payload.prompt ?? '',
|
||||
contentType,
|
||||
bytes: payload.bytes ?? 0,
|
||||
imageSrc: `data:${contentType};base64,${payload.image_b64}`,
|
||||
loading: false
|
||||
};
|
||||
} catch (e) {
|
||||
review = { ...r, loading: false, error: String(e) };
|
||||
}
|
||||
} else if (job.kind === 'description') {
|
||||
const r: DescriptionReview = {
|
||||
kind: 'description',
|
||||
jobId: job.id,
|
||||
@@ -236,28 +249,30 @@
|
||||
};
|
||||
review = r;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
let payload: {
|
||||
instructions?: string;
|
||||
old_description?: string;
|
||||
new_description?: string;
|
||||
} = {};
|
||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||
let payload: {
|
||||
instructions?: string;
|
||||
old_description?: string;
|
||||
new_description?: string;
|
||||
} = {};
|
||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||
|
||||
r.instructions = payload.instructions ?? '';
|
||||
r.oldDescription = payload.old_description ?? '';
|
||||
r.newDescription = payload.new_description ?? '';
|
||||
r.loading = false;
|
||||
} catch (e) {
|
||||
r.loading = false;
|
||||
r.error = String(e);
|
||||
}
|
||||
review = {
|
||||
...r,
|
||||
instructions: payload.instructions ?? '',
|
||||
oldDescription: payload.old_description ?? '',
|
||||
newDescription: payload.new_description ?? '',
|
||||
loading: false
|
||||
};
|
||||
} catch (e) {
|
||||
review = { ...r, loading: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeReview() {
|
||||
review = null;
|
||||
@@ -405,7 +420,9 @@
|
||||
|
||||
function fmtDate(s: string | undefined) {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleString(undefined, {
|
||||
const d = new Date(s);
|
||||
if (d.getFullYear() < 2000) return '—';
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
@@ -476,6 +493,27 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Bulk actions -->
|
||||
{#if stats.running + stats.pending > 0}
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
onclick={cancelAllRunning}
|
||||
disabled={cancellingAll}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{#if cancellingAll}
|
||||
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
Cancelling…
|
||||
{:else}
|
||||
Cancel all in-flight ({stats.running + stats.pending})
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<input
|
||||
|
||||
@@ -38,6 +38,46 @@
|
||||
|
||||
$effect(() => { void imgModel; void numSteps; void width; void height; saveConfig(); });
|
||||
|
||||
// ── Visibility management ─────────────────────────────────────────────────────
|
||||
type VisBook = { slug: string; vis: string; busy: boolean };
|
||||
let visBooks = $state<VisBook[]>([]);
|
||||
let visSlugInput = $state('');
|
||||
let visError = $state('');
|
||||
|
||||
async function addVisBook(e: Event) {
|
||||
e.preventDefault();
|
||||
const slug = visSlugInput.trim();
|
||||
if (!slug || visBooks.some((b) => b.slug === slug)) return;
|
||||
visError = '';
|
||||
try {
|
||||
const res = await fetch(`/api/books/${slug}`);
|
||||
if (!res.ok) { visError = `Book "${slug}" not found.`; return; }
|
||||
const book = await res.json() as { slug: string; visibility?: string };
|
||||
visBooks = [...visBooks, { slug: book.slug, vis: book.visibility ?? 'admin_only', busy: false }];
|
||||
visSlugInput = '';
|
||||
} catch {
|
||||
visError = 'Failed to load book.';
|
||||
}
|
||||
}
|
||||
|
||||
function removeVisBook(slug: string) {
|
||||
visBooks = visBooks.filter((b) => b.slug !== slug);
|
||||
}
|
||||
|
||||
async function toggleVisibility(slug: string, currentVis: string) {
|
||||
visBooks = visBooks.map((b) => b.slug === slug ? { ...b, busy: true } : b);
|
||||
const action = currentVis === 'public' ? 'unpublish' : 'publish';
|
||||
try {
|
||||
const res = await fetch(`/api/admin/books/${slug}/${action}`, { method: 'PATCH' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const newVis = action === 'publish' ? 'public' : 'admin_only';
|
||||
visBooks = visBooks.map((b) => b.slug === slug ? { ...b, vis: newVis, busy: false } : b);
|
||||
} catch (e) {
|
||||
visError = String(e);
|
||||
visBooks = visBooks.map((b) => b.slug === slug ? { ...b, busy: false } : b);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Batch covers ──────────────────────────────────────────────────────────────
|
||||
let fromItem = $state(0);
|
||||
let toItem = $state(0);
|
||||
@@ -276,4 +316,68 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Visibility management ───────────────────────────────────────────────── -->
|
||||
<div class="rounded-xl border border-(--color-border) bg-(--color-surface-2) p-5 space-y-4 mt-6">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold">Visibility management</h2>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">
|
||||
Scraped books default to <code class="text-xs bg-(--color-surface-3) px-1 py-0.5 rounded">admin_only</code>.
|
||||
Publish individual books to make them visible to all users, or unpublish to restrict to admins.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#snippet visMsg(msg: string, ok: boolean)}
|
||||
<p class="text-sm {ok ? 'text-green-400' : 'text-(--color-danger)'}">{msg}</p>
|
||||
{/snippet}
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each visBooks as vb (vb.slug)}
|
||||
<div class="flex items-center gap-3 rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2">
|
||||
<span class="font-mono text-sm flex-1 truncate">{vb.slug}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full {vb.vis === 'public'
|
||||
? 'bg-green-500/15 text-green-400 border border-green-500/30'
|
||||
: 'bg-(--color-surface-3) text-(--color-muted) border border-(--color-border)'}">
|
||||
{vb.vis === 'public' ? 'public' : 'admin only'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => toggleVisibility(vb.slug, vb.vis)}
|
||||
disabled={vb.busy}
|
||||
class="text-xs px-2.5 py-1 rounded border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{vb.busy ? '…' : vb.vis === 'public' ? 'Unpublish' : 'Publish'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeVisBook(vb.slug)}
|
||||
class="text-(--color-muted) hover:text-(--color-danger) transition-colors"
|
||||
aria-label="Remove"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<form onsubmit={addVisBook} class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={visSlugInput}
|
||||
placeholder="book-slug"
|
||||
class="flex-1 rounded-lg border border-(--color-border) bg-(--color-surface) px-3 py-2 text-sm font-mono text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 rounded-lg border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 transition-colors"
|
||||
>
|
||||
Load book
|
||||
</button>
|
||||
</form>
|
||||
{#if visError}
|
||||
{@render visMsg(visError, false)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface ImportTask {
|
||||
id: string;
|
||||
@@ -7,6 +8,11 @@
|
||||
title: string;
|
||||
file_name: string;
|
||||
file_type: string;
|
||||
author: string;
|
||||
cover_url: string;
|
||||
genres: string[];
|
||||
summary: string;
|
||||
book_status: string;
|
||||
status: string;
|
||||
chapters_done: number;
|
||||
chapters_total: number;
|
||||
@@ -18,17 +24,35 @@
|
||||
interface PendingImport {
|
||||
file: File;
|
||||
title: string;
|
||||
author: string;
|
||||
coverUrl: string;
|
||||
genres: string;
|
||||
summary: string;
|
||||
bookStatus: string;
|
||||
preview: { chapters: number; firstLines: string[] };
|
||||
}
|
||||
|
||||
let tasks = $state<ImportTask[]>([]);
|
||||
let loading = $state(true);
|
||||
let uploading = $state(false);
|
||||
let title = $state('');
|
||||
let error = $state('');
|
||||
let selectedFile = $state<File | null>(null);
|
||||
let pendingImport = $state<PendingImport | null>(null);
|
||||
let analyzing = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Form fields
|
||||
let selectedFile = $state<File | null>(null);
|
||||
let title = $state('');
|
||||
let author = $state('');
|
||||
let coverUrl = $state('');
|
||||
let genres = $state('');
|
||||
let summary = $state('');
|
||||
let bookStatus = $state('completed');
|
||||
|
||||
let pendingImport = $state<PendingImport | null>(null);
|
||||
|
||||
// AI panel: slug of recently completed import
|
||||
let aiSlug = $state('');
|
||||
let aiTitle = $state('');
|
||||
let showAiPanel = $state(false);
|
||||
|
||||
async function loadTasks() {
|
||||
loading = true;
|
||||
@@ -45,7 +69,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileSelect(e: Event) {
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files?.length) return;
|
||||
const file = input.files[0];
|
||||
@@ -54,8 +78,12 @@
|
||||
error = 'Please select a PDF or EPUB file';
|
||||
return;
|
||||
}
|
||||
error = '';
|
||||
selectedFile = file;
|
||||
title = file.name.replace(/\.(pdf|epub)$/i, '').replace(/[-_]/g, ' ');
|
||||
// Auto-fill title from filename if empty
|
||||
if (!title.trim()) {
|
||||
title = file.name.replace(/\.(pdf|epub)$/i, '').replace(/[-_]/g, ' ');
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeFile() {
|
||||
@@ -65,24 +93,26 @@
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('title', title);
|
||||
formData.append('title', title.trim());
|
||||
formData.append('analyze', 'true');
|
||||
const res = await fetch('/api/admin/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const res = await fetch('/api/admin/import', { method: 'POST', body: formData });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
pendingImport = {
|
||||
file: selectedFile,
|
||||
title: title,
|
||||
title: title.trim(),
|
||||
author: author.trim(),
|
||||
coverUrl: coverUrl.trim(),
|
||||
genres: genres.trim(),
|
||||
summary: summary.trim(),
|
||||
bookStatus,
|
||||
preview: data.preview || { chapters: 0, firstLines: [] }
|
||||
};
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
error = d.error || 'Failed to analyze file';
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
error = 'Failed to analyze file';
|
||||
} finally {
|
||||
analyzing = false;
|
||||
@@ -97,20 +127,36 @@
|
||||
const formData = new FormData();
|
||||
formData.append('file', pendingImport.file);
|
||||
formData.append('title', pendingImport.title);
|
||||
const res = await fetch('/api/admin/import', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
formData.append('author', pendingImport.author);
|
||||
formData.append('cover_url', pendingImport.coverUrl);
|
||||
formData.append('genres', pendingImport.genres);
|
||||
formData.append('summary', pendingImport.summary);
|
||||
formData.append('book_status', pendingImport.bookStatus);
|
||||
const res = await fetch('/api/admin/import', { method: 'POST', body: formData });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Save for AI panel before clearing state
|
||||
const importedSlug = data.slug || '';
|
||||
const importedTitle = pendingImport.title;
|
||||
// Reset form
|
||||
pendingImport = null;
|
||||
selectedFile = null;
|
||||
title = '';
|
||||
author = '';
|
||||
coverUrl = '';
|
||||
genres = '';
|
||||
summary = '';
|
||||
bookStatus = 'completed';
|
||||
// Show AI panel for this slug
|
||||
aiSlug = importedSlug;
|
||||
aiTitle = importedTitle;
|
||||
showAiPanel = !!aiSlug;
|
||||
await loadTasks();
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
error = d.error || 'Import failed';
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
error = 'Import failed';
|
||||
} finally {
|
||||
uploading = false;
|
||||
@@ -123,152 +169,313 @@
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString();
|
||||
const d = new Date(dateStr);
|
||||
if (d.getFullYear() < 2000) return '-';
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
function statusColor(status: string) {
|
||||
switch (status) {
|
||||
case 'pending': return 'text-yellow-400';
|
||||
case 'running': return 'text-blue-400';
|
||||
case 'done': return 'text-green-400';
|
||||
case 'failed': return 'text-red-400';
|
||||
default: return 'text-gray-400';
|
||||
default: return 'text-(--color-muted)';
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadTasks();
|
||||
onMount(() => { loadTasks(); });
|
||||
|
||||
// Poll every 3s while any task is active
|
||||
$effect(() => {
|
||||
const hasActive = tasks.some((t) => t.status === 'running' || t.status === 'pending');
|
||||
if (!hasActive) return;
|
||||
const timer = setInterval(() => { loadTasks(); }, 3000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
// Poll every 3s while any task is running
|
||||
// When a running task finishes, surface the AI panel for it
|
||||
$effect(() => {
|
||||
const hasRunning = tasks.some((t) => t.status === 'running' || t.status === 'pending');
|
||||
if (!hasRunning) return;
|
||||
const timer = setInterval(() => {
|
||||
loadTasks();
|
||||
}, 3000);
|
||||
return () => clearInterval(timer);
|
||||
if (!showAiPanel) {
|
||||
const done = tasks.find((t) => t.status === 'done');
|
||||
if (done && !aiSlug) {
|
||||
aiSlug = done.slug;
|
||||
aiTitle = done.title;
|
||||
showAiPanel = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="max-w-4xl">
|
||||
<h1 class="text-2xl font-bold mb-6">Import PDF/EPUB</h1>
|
||||
<div class="max-w-3xl space-y-8">
|
||||
<h1 class="text-2xl font-bold">Import PDF/EPUB</h1>
|
||||
|
||||
{#if pendingImport}
|
||||
<!-- Review Step -->
|
||||
<div class="mb-8 p-6 bg-(--color-surface-2) rounded-lg border border-(--color-brand)/30">
|
||||
<h2 class="text-lg font-semibold mb-4">Review Import</h2>
|
||||
<div class="space-y-3 mb-6">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-(--color-muted)">Title:</span>
|
||||
<span class="font-medium">{pendingImport.title}</span>
|
||||
<!-- ── Review step ── -->
|
||||
<div class="p-6 bg-(--color-surface-2) rounded-lg border border-(--color-brand)/30 space-y-4">
|
||||
<h2 class="text-lg font-semibold">Review Import</h2>
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-(--color-muted) shrink-0">Title</dt>
|
||||
<dd class="font-medium text-right">{pendingImport.title}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-(--color-muted)">File:</span>
|
||||
<span>{pendingImport.file.name}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-(--color-muted)">Size:</span>
|
||||
<span>{(pendingImport.file.size / 1024 / 1024).toFixed(2)} MB</span>
|
||||
</div>
|
||||
{#if pendingImport.preview.chapters > 0}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-(--color-muted)">Detected chapters:</span>
|
||||
<span class="text-green-400">{pendingImport.preview.chapters}</span>
|
||||
{#if pendingImport.author}
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-(--color-muted) shrink-0">Author</dt>
|
||||
<dd class="text-right">{pendingImport.author}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
{#if pendingImport.genres}
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-(--color-muted) shrink-0">Genres</dt>
|
||||
<dd class="text-right">{pendingImport.genres}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-(--color-muted) shrink-0">Status</dt>
|
||||
<dd class="capitalize text-right">{pendingImport.bookStatus}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-(--color-muted) shrink-0">File</dt>
|
||||
<dd class="text-right truncate max-w-xs">{pendingImport.file.name}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-(--color-muted) shrink-0">Size</dt>
|
||||
<dd>{(pendingImport.file.size / 1024 / 1024).toFixed(2)} MB</dd>
|
||||
</div>
|
||||
{#if pendingImport.preview.chapters > 0}
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-(--color-muted) shrink-0">Detected chapters</dt>
|
||||
<dd class="text-green-400 font-semibold">{pendingImport.preview.chapters}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
{#if pendingImport.preview.firstLines?.length}
|
||||
<div class="mt-2 space-y-1">
|
||||
<p class="text-xs text-(--color-muted) mb-1">First lines preview:</p>
|
||||
{#each pendingImport.preview.firstLines as line}
|
||||
<p class="text-xs text-(--color-muted) italic truncate">{line}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button
|
||||
onclick={startImport}
|
||||
disabled={uploading}
|
||||
class="px-4 py-2 bg-green-600 text-white rounded font-medium disabled:opacity-50"
|
||||
class="px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded font-medium disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{uploading ? 'Starting...' : 'Start Import'}
|
||||
{uploading ? 'Starting…' : 'Start Import'}
|
||||
</button>
|
||||
<button
|
||||
onclick={cancelReview}
|
||||
class="px-4 py-2 border border-(--color-border) rounded font-medium"
|
||||
class="px-4 py-2 border border-(--color-border) rounded font-medium hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- Upload Form -->
|
||||
<form onsubmit={(e) => { e.preventDefault(); analyzeFile(); }} class="mb-8 p-4 bg-(--color-surface-2) rounded-lg">
|
||||
<div class="mb-4">
|
||||
<label for="import-file" class="block text-sm font-medium mb-2">Select File (PDF or EPUB)</label>
|
||||
<!-- ── Upload form ── -->
|
||||
<form
|
||||
onsubmit={(e) => { e.preventDefault(); analyzeFile(); }}
|
||||
class="p-6 bg-(--color-surface-2) rounded-lg space-y-4"
|
||||
>
|
||||
<!-- File picker -->
|
||||
<div>
|
||||
<label for="import-file" class="block text-sm font-medium mb-1">File (PDF or EPUB)</label>
|
||||
<input
|
||||
id="import-file"
|
||||
type="file"
|
||||
accept=".pdf,.epub"
|
||||
onchange={handleFileSelect}
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text)"
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="import-title" class="block text-sm font-medium mb-2">Book Title</label>
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label for="import-title" class="block text-sm font-medium mb-1">Title <span class="text-red-400">*</span></label>
|
||||
<input
|
||||
id="import-title"
|
||||
type="text"
|
||||
bind:value={title}
|
||||
placeholder="Enter book title"
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text)"
|
||||
placeholder="Book title"
|
||||
required
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Author -->
|
||||
<div>
|
||||
<label for="import-author" class="block text-sm font-medium mb-1">Author</label>
|
||||
<input
|
||||
id="import-author"
|
||||
type="text"
|
||||
bind:value={author}
|
||||
placeholder="Author name"
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Cover URL -->
|
||||
<div>
|
||||
<label for="import-cover" class="block text-sm font-medium mb-1">Cover image URL</label>
|
||||
<input
|
||||
id="import-cover"
|
||||
type="url"
|
||||
bind:value={coverUrl}
|
||||
placeholder="https://…"
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Genres -->
|
||||
<div>
|
||||
<label for="import-genres" class="block text-sm font-medium mb-1">Genres <span class="text-xs text-(--color-muted)">(comma-separated)</span></label>
|
||||
<input
|
||||
id="import-genres"
|
||||
type="text"
|
||||
bind:value={genres}
|
||||
placeholder="Fantasy, Action, Romance"
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div>
|
||||
<label for="import-summary" class="block text-sm font-medium mb-1">Summary</label>
|
||||
<textarea
|
||||
id="import-summary"
|
||||
bind:value={summary}
|
||||
rows={3}
|
||||
placeholder="Short description of the book…"
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm resize-y"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<label for="import-status" class="block text-sm font-medium mb-1">Book status</label>
|
||||
<select
|
||||
id="import-status"
|
||||
bind:value={bookStatus}
|
||||
class="px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||
>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="ongoing">Ongoing</option>
|
||||
<option value="hiatus">Hiatus</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="mb-4 text-sm text-red-400">{error}</p>
|
||||
<p class="text-sm text-red-400">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={analyzing || !selectedFile || !title.trim()}
|
||||
class="px-4 py-2 bg-(--color-brand) text-(--color-surface) rounded font-medium disabled:opacity-50"
|
||||
class="px-5 py-2 bg-(--color-brand) text-(--color-surface) rounded font-semibold disabled:opacity-50 hover:brightness-110 transition-all"
|
||||
>
|
||||
{analyzing ? 'Analyzing...' : 'Review & Import'}
|
||||
{analyzing ? 'Analyzing…' : 'Review & Import'}
|
||||
</button>
|
||||
<p class="mt-2 text-xs text-(--color-muted)">
|
||||
Select a file to preview chapter count before importing.
|
||||
</p>
|
||||
<p class="text-xs text-(--color-muted)">Detects chapter structure before committing.</p>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- Task List -->
|
||||
<h2 class="text-lg font-semibold mb-4">Import Tasks</h2>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-(--color-muted)">Loading...</p>
|
||||
{:else if tasks.length === 0}
|
||||
<p class="text-(--color-muted)">No import tasks yet.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-(--color-muted) border-b border-(--color-border)">
|
||||
<th class="pb-2">Title</th>
|
||||
<th class="pb-2">Type</th>
|
||||
<th class="pb-2">Status</th>
|
||||
<th class="pb-2">Chapters</th>
|
||||
<th class="pb-2">Started</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tasks as task}
|
||||
<tr class="border-b border-(--color-border)/50">
|
||||
<td class="py-2">
|
||||
<div class="font-medium">{task.title}</div>
|
||||
<div class="text-xs text-(--color-muted)">{task.slug}</div>
|
||||
</td>
|
||||
<td class="py-2 uppercase text-xs">{task.file_type}</td>
|
||||
<td class="py-2 {getStatusColor(task.status)}">{task.status}</td>
|
||||
<td class="py-2 text-(--color-muted)">
|
||||
{task.chapters_done}/{task.chapters_total}
|
||||
</td>
|
||||
<td class="py-2 text-(--color-muted)">{formatDate(task.started)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- ── AI Tasks panel (shown after successful import) ── -->
|
||||
{#if showAiPanel && aiSlug}
|
||||
<div class="p-5 bg-(--color-surface-2) rounded-lg border border-(--color-brand)/20 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold">AI Tasks for <span class="text-(--color-brand)">{aiTitle || aiSlug}</span></h2>
|
||||
<button
|
||||
onclick={() => { showAiPanel = false; }}
|
||||
class="text-(--color-muted) hover:text-(--color-text) text-lg leading-none"
|
||||
aria-label="Dismiss"
|
||||
>×</button>
|
||||
</div>
|
||||
<p class="text-sm text-(--color-muted)">Run AI tasks on the imported book to enrich it:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a
|
||||
href="/admin/text-gen?slug={aiSlug}&tab=chapters"
|
||||
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
|
||||
>
|
||||
Generate chapter names
|
||||
</a>
|
||||
<a
|
||||
href="/admin/text-gen?slug={aiSlug}&tab=description"
|
||||
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
|
||||
>
|
||||
Generate description
|
||||
</a>
|
||||
<a
|
||||
href="/admin/image-gen?slug={aiSlug}"
|
||||
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
|
||||
>
|
||||
Generate cover image
|
||||
</a>
|
||||
<a
|
||||
href="/admin/text-gen?slug={aiSlug}&tab=tagline"
|
||||
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
|
||||
>
|
||||
Generate tagline
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Task list ── -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-3">Import Tasks</h2>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-(--color-muted) text-sm">Loading…</p>
|
||||
{:else if tasks.length === 0}
|
||||
<p class="text-(--color-muted) text-sm">No import tasks yet.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-lg border border-(--color-border)">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-(--color-muted) border-b border-(--color-border) bg-(--color-surface-2)">
|
||||
<th class="px-3 py-2 font-medium">Title</th>
|
||||
<th class="px-3 py-2 font-medium">Type</th>
|
||||
<th class="px-3 py-2 font-medium">Status</th>
|
||||
<th class="px-3 py-2 font-medium">Chapters</th>
|
||||
<th class="px-3 py-2 font-medium">Started</th>
|
||||
<th class="px-3 py-2 font-medium">AI</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tasks as task}
|
||||
<tr class="border-b border-(--color-border)/50 hover:bg-(--color-surface-2)/50">
|
||||
<td class="px-3 py-2">
|
||||
<div class="font-medium">{task.title}</div>
|
||||
<div class="text-xs text-(--color-muted)">{task.slug}</div>
|
||||
{#if task.error_message}
|
||||
<div class="text-xs text-red-400 mt-0.5 truncate max-w-xs" title={task.error_message}>{task.error_message}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-3 py-2 uppercase text-xs">{task.file_type}</td>
|
||||
<td class="px-3 py-2 {statusColor(task.status)} font-medium">{task.status}</td>
|
||||
<td class="px-3 py-2 text-(--color-muted)">
|
||||
{task.chapters_done}/{task.chapters_total}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-(--color-muted) text-xs whitespace-nowrap">{formatDate(task.started)}</td>
|
||||
<td class="px-3 py-2">
|
||||
{#if task.status === 'done'}
|
||||
<button
|
||||
onclick={() => { aiSlug = task.slug; aiTitle = task.title; showAiPanel = true; }}
|
||||
class="text-xs px-2 py-1 rounded bg-(--color-brand)/20 hover:bg-(--color-brand)/40 text-(--color-brand) transition-colors"
|
||||
>
|
||||
AI tasks
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
16
ui/src/routes/admin/notifications/+page.server.ts
Normal file
16
ui/src/routes/admin/notifications/+page.server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const userId = locals.user!.id;
|
||||
try {
|
||||
const res = await backendFetch('/api/notifications?user_id=' + userId);
|
||||
const data = await res.json().catch(() => ({ notifications: [] }));
|
||||
return {
|
||||
userId,
|
||||
notifications: (data.notifications ?? []) as Array<{id: string; title: string; message: string; link: string; read: boolean}>
|
||||
};
|
||||
} catch {
|
||||
return { userId, notifications: [] };
|
||||
}
|
||||
};
|
||||
149
ui/src/routes/admin/notifications/+page.svelte
Normal file
149
ui/src/routes/admin/notifications/+page.svelte
Normal file
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
type Notification = { id: string; title: string; message: string; link: string; read: boolean };
|
||||
|
||||
let notifications = $state<Notification[]>(data.notifications);
|
||||
let filter = $state<'all' | 'unread'>('all');
|
||||
let busy = $state(false);
|
||||
|
||||
const filtered = $derived(
|
||||
filter === 'unread' ? notifications.filter(n => !n.read) : notifications
|
||||
);
|
||||
const unreadCount = $derived(notifications.filter(n => !n.read).length);
|
||||
|
||||
async function markRead(id: string) {
|
||||
await fetch('/api/notifications/' + id, { method: 'PATCH' }).catch(() => {});
|
||||
notifications = notifications.map(n => n.id === id ? { ...n, read: true } : n);
|
||||
}
|
||||
|
||||
async function dismiss(id: string) {
|
||||
await fetch('/api/notifications/' + id, { method: 'DELETE' }).catch(() => {});
|
||||
notifications = notifications.filter(n => n.id !== id);
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
busy = true;
|
||||
try {
|
||||
await fetch('/api/notifications?user_id=' + data.userId, { method: 'PATCH' });
|
||||
notifications = notifications.map(n => ({ ...n, read: true }));
|
||||
} finally { busy = false; }
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
if (!confirm('Clear all notifications?')) return;
|
||||
busy = true;
|
||||
try {
|
||||
await fetch('/api/notifications?user_id=' + data.userId, { method: 'DELETE' });
|
||||
notifications = [];
|
||||
} finally { busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Notifications — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto px-4 py-8">
|
||||
<!-- Broadcast panel -->
|
||||
<div class="mb-6 rounded-lg border border-(--color-border) bg-(--color-surface-2) p-4 flex items-start gap-3">
|
||||
<svg class="w-5 h-5 text-(--color-brand) mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
|
||||
</svg>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold text-(--color-text)">Broadcast to users</p>
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">To send push notifications or in-app messages to all subscribers, use the push dashboard.</p>
|
||||
<a
|
||||
href="https://push.libnovel.cc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 mt-2 text-sm text-(--color-brand) hover:underline"
|
||||
>
|
||||
Open push.libnovel.cc
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold">Your Notification Inbox</h1>
|
||||
{#if unreadCount > 0}
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">{unreadCount} unread</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#if unreadCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={markAllRead}
|
||||
disabled={busy}
|
||||
class="text-sm px-3 py-1.5 rounded border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors disabled:opacity-50"
|
||||
>Mark all read</button>
|
||||
{/if}
|
||||
{#if notifications.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearAll}
|
||||
disabled={busy}
|
||||
class="text-sm px-3 py-1.5 rounded border border-(--color-border) text-red-400 hover:bg-(--color-surface-2) transition-colors disabled:opacity-50"
|
||||
>Clear all</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter tabs -->
|
||||
<div class="flex gap-0 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filter = 'all'}
|
||||
class="text-sm px-4 py-1.5 rounded-l border border-(--color-border) transition-colors {filter === 'all' ? 'bg-(--color-brand) text-black border-(--color-brand) font-medium' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'}"
|
||||
>All ({notifications.length})</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filter = 'unread'}
|
||||
class="text-sm px-4 py-1.5 rounded-r border border-l-0 border-(--color-border) transition-colors {filter === 'unread' ? 'bg-(--color-brand) text-black border-(--color-brand) font-medium' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'}"
|
||||
>Unread ({unreadCount})</button>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
{#if filtered.length === 0}
|
||||
<div class="py-16 text-center text-(--color-muted)">
|
||||
{filter === 'unread' ? 'No unread notifications' : 'No notifications'}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-(--color-border) overflow-hidden">
|
||||
{#each filtered as n (n.id)}
|
||||
<div class="flex items-start gap-2 border-b border-(--color-border)/40 last:border-b-0 hover:bg-(--color-surface-2) group transition-colors {n.read ? 'opacity-60' : ''}">
|
||||
<a
|
||||
href={n.link || '/admin'}
|
||||
onclick={() => markRead(n.id)}
|
||||
class="flex-1 p-4 min-w-0"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !n.read}
|
||||
<span class="w-2 h-2 rounded-full bg-(--color-brand) shrink-0"></span>
|
||||
{/if}
|
||||
<span class="font-medium text-sm">{n.title}</span>
|
||||
</div>
|
||||
<p class="text-sm text-(--color-muted) mt-1">{n.message}</p>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => dismiss(n.id)}
|
||||
class="shrink-0 p-3 text-(--color-muted) hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
7
ui/src/routes/admin/site-theme/+page.server.ts
Normal file
7
ui/src/routes/admin/site-theme/+page.server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getSiteConfig } from '$lib/server/pocketbase';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const config = await getSiteConfig();
|
||||
return { config };
|
||||
};
|
||||
160
ui/src/routes/admin/site-theme/+page.svelte
Normal file
160
ui/src/routes/admin/site-theme/+page.svelte
Normal file
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
type Decoration = 'snow' | 'sakura' | 'fireflies' | 'leaves' | 'stars' | null;
|
||||
type LogoAnimation = 'none' | 'glow' | 'rainbow' | 'pulse' | 'shimmer';
|
||||
|
||||
let decoration = $state<Decoration>(data.config.decoration);
|
||||
let logoAnimation = $state<LogoAnimation>(data.config.logoAnimation);
|
||||
let eventLabel = $state(data.config.eventLabel ?? '');
|
||||
|
||||
let saving = $state(false);
|
||||
let saved = $state(false);
|
||||
let errMsg = $state('');
|
||||
|
||||
const DECORATIONS: { id: Decoration; label: string; emoji: string; desc: string }[] = [
|
||||
{ id: null, label: 'Off', emoji: '✕', desc: 'No decoration' },
|
||||
{ id: 'snow', label: 'Snow', emoji: '❄️', desc: 'Falling snowflakes — winter' },
|
||||
{ id: 'sakura', label: 'Sakura', emoji: '🌸', desc: 'Cherry blossom petals — spring' },
|
||||
{ id: 'fireflies', label: 'Fireflies', emoji: '✨', desc: 'Glowing fireflies — summer' },
|
||||
{ id: 'leaves', label: 'Leaves', emoji: '🍂', desc: 'Falling autumn leaves — fall' },
|
||||
{ id: 'stars', label: 'Stars', emoji: '⭐', desc: 'Twinkling stars — events / fantasy' },
|
||||
];
|
||||
|
||||
const LOGO_ANIMATIONS: { id: LogoAnimation; label: string; desc: string }[] = [
|
||||
{ id: 'none', label: 'None', desc: 'Default brand colour, no animation' },
|
||||
{ id: 'glow', label: 'Glow', desc: 'Soft pulsing amber glow' },
|
||||
{ id: 'shimmer', label: 'Shimmer', desc: 'Left-to-right shine sweep' },
|
||||
{ id: 'pulse', label: 'Pulse', desc: 'Subtle scale pulse' },
|
||||
{ id: 'rainbow', label: 'Rainbow', desc: 'Slow hue-rotate colour cycle' },
|
||||
];
|
||||
|
||||
async function save() {
|
||||
saving = true; saved = false; errMsg = '';
|
||||
try {
|
||||
const res = await fetch('/api/site-config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ decoration, logoAnimation, eventLabel }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
errMsg = d.message ?? `Error ${res.status}`;
|
||||
} else {
|
||||
saved = true;
|
||||
setTimeout(() => { saved = false; }, 3000);
|
||||
}
|
||||
} catch (e) {
|
||||
errMsg = String(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Site Theme — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-(--color-text) mb-1">Site Theme</h1>
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
Control seasonal decorations and the nav logo animation globally.
|
||||
Changes take effect for all users within ~60 seconds (server cache TTL).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Decoration ────────────────────────────────────────────────────── -->
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-(--color-text) uppercase tracking-widest mb-3">Particle Decoration</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{#each DECORATIONS as d}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { decoration = d.id; }}
|
||||
class="flex items-start gap-3 p-3 rounded-lg border text-left transition-all
|
||||
{decoration === d.id
|
||||
? 'border-(--color-brand) bg-(--color-surface-2) text-(--color-text)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2)/40 text-(--color-muted) hover:border-(--color-brand)/40 hover:text-(--color-text)'}"
|
||||
>
|
||||
<span class="text-xl leading-none shrink-0 mt-0.5">{d.emoji}</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold leading-snug">{d.label}</p>
|
||||
<p class="text-xs opacity-70 leading-snug mt-0.5">{d.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Logo Animation ────────────────────────────────────────────────── -->
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-(--color-text) uppercase tracking-widest mb-3">Logo Animation</h2>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each LOGO_ANIMATIONS as a}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { logoAnimation = a.id; }}
|
||||
class="flex items-center gap-4 p-3 rounded-lg border text-left transition-all
|
||||
{logoAnimation === a.id
|
||||
? 'border-(--color-brand) bg-(--color-surface-2) text-(--color-text)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2)/40 text-(--color-muted) hover:border-(--color-brand)/40 hover:text-(--color-text)'}"
|
||||
>
|
||||
<!-- Preview of the logo text with the animation class applied -->
|
||||
<span class="font-bold text-lg tracking-tight w-24 shrink-0 text-(--color-brand)
|
||||
{a.id === 'glow' ? 'logo-anim-glow' : ''}
|
||||
{a.id === 'shimmer' ? 'logo-anim-shimmer' : ''}
|
||||
{a.id === 'pulse' ? 'logo-anim-pulse' : ''}
|
||||
{a.id === 'rainbow' ? 'logo-anim-rainbow' : ''}
|
||||
">libnovel</span>
|
||||
<div>
|
||||
<p class="text-sm font-semibold">{a.label}</p>
|
||||
<p class="text-xs opacity-70">{a.desc}</p>
|
||||
</div>
|
||||
{#if logoAnimation === a.id}
|
||||
<svg class="w-4 h-4 ml-auto shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Event Label ───────────────────────────────────────────────────── -->
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-(--color-text) uppercase tracking-widest mb-1">Event Label <span class="normal-case font-normal text-(--color-muted)">(optional)</span></h2>
|
||||
<p class="text-xs text-(--color-muted) mb-3">Short text shown as a small badge next to the logo, e.g. "Winter 2025" or "Sakura Festival". Leave blank to hide.</p>
|
||||
<input
|
||||
type="text"
|
||||
maxlength="64"
|
||||
placeholder="e.g. Winter 2025"
|
||||
bind:value={eventLabel}
|
||||
class="w-full px-3 py-2 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-text) text-sm placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand)/60"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- ── Save ──────────────────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={save}
|
||||
disabled={saving}
|
||||
class="px-5 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
{#if saved}
|
||||
<span class="text-sm text-green-400 flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
||||
Saved
|
||||
</span>
|
||||
{/if}
|
||||
{#if errMsg}
|
||||
<span class="text-sm text-red-400">{errMsg}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -9,16 +9,18 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
redirect(302, '/');
|
||||
}
|
||||
|
||||
const [books, jobs] = await Promise.all([
|
||||
listBookSlugs().catch((e): Awaited<ReturnType<typeof listBookSlugs>> => {
|
||||
log.warn('admin/translation', 'failed to load book slugs', { err: String(e) });
|
||||
return [];
|
||||
}),
|
||||
listTranslationJobs().catch((e): TranslationJob[] => {
|
||||
log.warn('admin/translation', 'failed to load translation jobs', { err: String(e) });
|
||||
return [];
|
||||
})
|
||||
]);
|
||||
// Stream jobs — navigation is instant, list populates shortly after.
|
||||
const jobs = listTranslationJobs().catch((e): TranslationJob[] => {
|
||||
log.warn('admin/translation', 'failed to load translation jobs', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
|
||||
// Books list is needed immediately for the enqueue form, but use cache so
|
||||
// it's fast on repeat visits.
|
||||
const books = await listBookSlugs().catch((e): Awaited<ReturnType<typeof listBookSlugs>> => {
|
||||
log.warn('admin/translation', 'failed to load book slugs', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
|
||||
return { books, jobs };
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import type { TranslationJob } from '$lib/server/pocketbase';
|
||||
@@ -7,11 +6,11 @@
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let jobs = $state<TranslationJob[]>(untrack(() => data.jobs));
|
||||
let jobs = $state<TranslationJob[]>([]);
|
||||
|
||||
// Keep in sync on server reloads
|
||||
// Resolve streamed promise; re-runs on server reloads (invalidateAll)
|
||||
$effect(() => {
|
||||
jobs = data.jobs;
|
||||
Promise.resolve(data.jobs).then((resolved) => { jobs = resolved; });
|
||||
});
|
||||
|
||||
// ── Live-poll while any job is in-flight ─────────────────────────────────────
|
||||
|
||||
34
ui/src/routes/api/admin/books/[slug]/archive/+server.ts
Normal file
34
ui/src/routes/api/admin/books/[slug]/archive/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* PATCH /api/admin/books/[slug]/archive
|
||||
* PATCH /api/admin/books/[slug]/unarchive (action param: ?action=unarchive)
|
||||
*
|
||||
* Admin-only proxy. Soft-deletes (archives) or restores a book.
|
||||
* Returns { slug, status: "archived" | "active" }.
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, url, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const { slug } = params;
|
||||
const action = url.searchParams.get('action') === 'unarchive' ? 'unarchive' : 'archive';
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch(`/api/admin/books/${encodeURIComponent(slug)}/${action}`, {
|
||||
method: 'PATCH'
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/books/archive', 'backend proxy error', { slug, action, err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
34
ui/src/routes/api/admin/books/[slug]/delete/+server.ts
Normal file
34
ui/src/routes/api/admin/books/[slug]/delete/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* DELETE /api/admin/books/[slug]/delete
|
||||
*
|
||||
* Admin-only proxy. Permanently removes a book and all its data:
|
||||
* PocketBase records, MinIO objects, and the Meilisearch document.
|
||||
* This operation is irreversible — use the archive endpoint for soft-deletion.
|
||||
* Returns { slug, status: "deleted" }.
|
||||
*/
|
||||
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const { slug } = params;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch(`/api/admin/books/${encodeURIComponent(slug)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('admin/books/delete', 'backend proxy error', { slug, err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data, { status: res.status });
|
||||
};
|
||||
17
ui/src/routes/api/admin/books/[slug]/publish/+server.ts
Normal file
17
ui/src/routes/api/admin/books/[slug]/publish/+server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') throw error(403, 'Forbidden');
|
||||
const { slug } = params;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch(`/api/admin/books/${encodeURIComponent(slug)}/publish`, { method: 'PATCH' });
|
||||
} catch (e) {
|
||||
log.error('admin/books/publish', 'backend proxy error', { slug, err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
return json(await res.json().catch(() => ({})), { status: res.status });
|
||||
};
|
||||
17
ui/src/routes/api/admin/books/[slug]/unpublish/+server.ts
Normal file
17
ui/src/routes/api/admin/books/[slug]/unpublish/+server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') throw error(403, 'Forbidden');
|
||||
const { slug } = params;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await backendFetch(`/api/admin/books/${encodeURIComponent(slug)}/unpublish`, { method: 'PATCH' });
|
||||
} catch (e) {
|
||||
log.error('admin/books/unpublish', 'backend proxy error', { slug, err: String(e) });
|
||||
throw error(502, 'Could not reach backend');
|
||||
}
|
||||
return json(await res.json().catch(() => ({})), { status: res.status });
|
||||
};
|
||||
17
ui/src/routes/api/audio/books/+server.ts
Normal file
17
ui/src/routes/api/audio/books/+server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getBooksWithAudioCount } from '$lib/server/pocketbase';
|
||||
|
||||
/**
|
||||
* GET /api/audio/books
|
||||
* Returns books that have at least one completed narrated chapter,
|
||||
* sorted by number of narrated chapters descending.
|
||||
* Cached 5 minutes at the CDN/proxy level.
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
const entries = await getBooksWithAudioCount(100).catch(() => []);
|
||||
return json(
|
||||
{ books: entries },
|
||||
{ headers: { 'Cache-Control': 'public, max-age=300' } }
|
||||
);
|
||||
};
|
||||
18
ui/src/routes/api/audio/chapters/+server.ts
Normal file
18
ui/src/routes/api/audio/chapters/+server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getReadyChaptersForSlug } from '$lib/server/pocketbase';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const slug = url.searchParams.get('slug') ?? '';
|
||||
if (!slug) error(400, 'slug is required');
|
||||
|
||||
const voice = url.searchParams.get('voice') ?? '';
|
||||
const readyMap = await getReadyChaptersForSlug(slug, voice);
|
||||
|
||||
// Return array of { chapter, voice } pairs
|
||||
const chapters = [...readyMap.entries()].map(([chapter, v]) => ({ chapter, voice: v }));
|
||||
|
||||
return json({ chapters }, {
|
||||
headers: { 'Cache-Control': 'public, max-age=60' }
|
||||
});
|
||||
};
|
||||
@@ -1,15 +1,24 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { parseAuthToken } from '../../../../hooks.server.js';
|
||||
import { deleteSessionByAuthId } from '$lib/server/pocketbase';
|
||||
|
||||
const AUTH_COOKIE = 'libnovel_auth';
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Clears the auth cookie and returns { ok: true }.
|
||||
* Does not revoke the session record from PocketBase —
|
||||
* for full revocation use DELETE /api/sessions/[id] first.
|
||||
* Deletes the session row from PocketBase AND clears the auth cookie, so the
|
||||
* session doesn't linger as a phantom "active session" after sign-out.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ cookies }) => {
|
||||
const token = cookies.get(AUTH_COOKIE);
|
||||
if (token) {
|
||||
const user = parseAuthToken(token);
|
||||
if (user?.authSessionId) {
|
||||
// Best-effort — non-fatal if PocketBase is unreachable.
|
||||
deleteSessionByAuthId(user.authSessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
cookies.delete(AUTH_COOKIE, { path: '/' });
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
15
ui/src/routes/api/books/[slug]/+server.ts
Normal file
15
ui/src/routes/api/books/[slug]/+server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* GET /api/books/[slug]
|
||||
* Returns basic book metadata (slug, title, visibility) for admin tooling.
|
||||
* Requires admin role — not a public endpoint.
|
||||
*/
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getBook } from '$lib/server/pocketbase';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') throw error(403, 'Forbidden');
|
||||
const book = await getBook(params.slug);
|
||||
if (!book) throw error(404, 'Book not found');
|
||||
return json({ slug: book.slug, title: book.title, visibility: book.visibility ?? 'admin_only' });
|
||||
};
|
||||
@@ -8,4 +8,22 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
const res = await backendFetch('/api/notifications?user_id=' + userId);
|
||||
const data = await res.json().catch(() => ({ notifications: [] }));
|
||||
return json(data);
|
||||
};
|
||||
};
|
||||
|
||||
// PATCH /api/notifications?user_id=<id> — mark all read
|
||||
export const PATCH: RequestHandler = async ({ url }) => {
|
||||
const userId = url.searchParams.get('user_id');
|
||||
if (!userId) throw error(400, 'user_id required');
|
||||
const res = await backendFetch('/api/notifications?user_id=' + userId, { method: 'PATCH' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data);
|
||||
};
|
||||
|
||||
// DELETE /api/notifications?user_id=<id> — clear all
|
||||
export const DELETE: RequestHandler = async ({ url }) => {
|
||||
const userId = url.searchParams.get('user_id');
|
||||
if (!userId) throw error(400, 'user_id required');
|
||||
const res = await backendFetch('/api/notifications?user_id=' + userId, { method: 'DELETE' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data);
|
||||
};
|
||||
|
||||
@@ -2,18 +2,19 @@ import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const userId = url.searchParams.get('user_id');
|
||||
if (!userId) throw error(400, 'user_id required');
|
||||
const res = await backendFetch('/api/notifications?user_id=' + userId);
|
||||
const data = await res.json().catch(() => ({ notifications: [] }));
|
||||
return json(data);
|
||||
};
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params }) => {
|
||||
const id = params.id;
|
||||
if (!id) throw error(400, 'id required');
|
||||
const res = await backendFetch('/api/notifications/' + id, { method: 'PATCH' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data);
|
||||
};
|
||||
};
|
||||
|
||||
// DELETE /api/notifications/[id] — dismiss a single notification
|
||||
export const DELETE: RequestHandler = async ({ params }) => {
|
||||
const id = params.id;
|
||||
if (!id) throw error(400, 'id required');
|
||||
const res = await backendFetch('/api/notifications/' + id, { method: 'DELETE' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data);
|
||||
};
|
||||
|
||||
64
ui/src/routes/api/profile/+server.ts
Normal file
64
ui/src/routes/api/profile/+server.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { deleteUserAccount, updateUserNotificationPrefs } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* PATCH /api/profile
|
||||
*
|
||||
* Update mutable profile preferences (currently: notification preferences).
|
||||
* Body: { notify_new_chapters?: boolean, notify_new_chapters_push?: boolean }
|
||||
*/
|
||||
export const PATCH: RequestHandler = async ({ locals, request }) => {
|
||||
if (!locals.user) error(401, 'Not authenticated');
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
error(400, 'Invalid JSON');
|
||||
}
|
||||
|
||||
const prefs: { notify_new_chapters?: boolean; notify_new_chapters_push?: boolean } = {};
|
||||
if (typeof body.notify_new_chapters === 'boolean') {
|
||||
prefs.notify_new_chapters = body.notify_new_chapters;
|
||||
}
|
||||
if (typeof body.notify_new_chapters_push === 'boolean') {
|
||||
prefs.notify_new_chapters_push = body.notify_new_chapters_push;
|
||||
}
|
||||
|
||||
if (Object.keys(prefs).length === 0) {
|
||||
error(400, 'No valid preferences provided');
|
||||
}
|
||||
|
||||
try {
|
||||
await updateUserNotificationPrefs(locals.user.id, prefs);
|
||||
} catch (e) {
|
||||
log.error('profile', 'PATCH /api/profile failed', { userId: locals.user.id, err: String(e) });
|
||||
error(500, { message: 'Failed to update preferences. Please try again.' });
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/profile
|
||||
*
|
||||
* Permanently deletes the authenticated user's account and all associated data:
|
||||
* settings, library, progress, votes, ratings, sessions, notifications.
|
||||
*
|
||||
* The app_users record is removed last. The caller should immediately log the
|
||||
* user out (submit the logout form) to clear the session cookie.
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user) error(401, 'Not authenticated');
|
||||
|
||||
try {
|
||||
await deleteUserAccount(locals.user.id, locals.sessionId);
|
||||
} catch (e) {
|
||||
log.error('profile', 'DELETE /api/profile failed', { userId: locals.user.id, err: String(e) });
|
||||
error(500, { message: 'Failed to delete account. Please try again or contact support.' });
|
||||
}
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
63
ui/src/routes/api/push-subscription/+server.ts
Normal file
63
ui/src/routes/api/push-subscription/+server.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
/**
|
||||
* POST /api/push-subscription
|
||||
* Registers a browser push subscription for the current user.
|
||||
* Body: { endpoint, keys: { p256dh, auth } }
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) throw error(401, 'Login required');
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.endpoint || !body?.keys?.p256dh || !body?.keys?.auth) {
|
||||
throw error(400, 'Invalid push subscription object');
|
||||
}
|
||||
|
||||
const res = await backendFetch('/api/push-subscriptions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_id: locals.user.id,
|
||||
endpoint: body.endpoint,
|
||||
p256dh: body.keys.p256dh,
|
||||
auth: body.keys.auth,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => 'backend error');
|
||||
throw error(res.status, msg);
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/push-subscription
|
||||
* Unregisters a push subscription by endpoint.
|
||||
* Body: { endpoint }
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user) throw error(401, 'Login required');
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body?.endpoint) throw error(400, 'endpoint required');
|
||||
|
||||
const res = await backendFetch('/api/push-subscriptions', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_id: locals.user.id,
|
||||
endpoint: body.endpoint,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = await res.text().catch(() => 'backend error');
|
||||
throw error(res.status, msg);
|
||||
}
|
||||
|
||||
return json({ success: true });
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/settings
|
||||
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize, announceChapter).
|
||||
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize, announceChapter, audioMode).
|
||||
* Returns defaults if no settings record exists yet.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
@@ -19,7 +19,8 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
locale: settings?.locale ?? 'en',
|
||||
fontFamily: settings?.font_family ?? 'system',
|
||||
fontSize: settings?.font_size || 1.0,
|
||||
announceChapter: settings?.announce_chapter ?? false
|
||||
announceChapter: settings?.announce_chapter ?? false,
|
||||
audioMode: settings?.audio_mode ?? 'stream'
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('settings', 'GET failed', { err: String(e) });
|
||||
@@ -29,7 +30,7 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
|
||||
/**
|
||||
* PUT /api/settings
|
||||
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number, announceChapter?: boolean }
|
||||
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number, announceChapter?: boolean, audioMode?: string }
|
||||
* Saves user preferences.
|
||||
*/
|
||||
export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
@@ -73,6 +74,12 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
error(400, 'Invalid announceChapter — must be boolean');
|
||||
}
|
||||
|
||||
// audioMode is optional — if provided it must be a known value
|
||||
const validAudioModes = ['stream', 'generate'];
|
||||
if (body.audioMode !== undefined && !validAudioModes.includes(body.audioMode)) {
|
||||
error(400, `Invalid audioMode — must be one of: ${validAudioModes.join(', ')}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await saveSettings(locals.sessionId, body, locals.user?.id);
|
||||
} catch (e) {
|
||||
|
||||
57
ui/src/routes/api/site-config/+server.ts
Normal file
57
ui/src/routes/api/site-config/+server.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getSiteConfig, saveSiteConfig } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/site-config
|
||||
* Public — returns current site-wide decoration/animation settings.
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
const config = await getSiteConfig();
|
||||
return json(config);
|
||||
} catch (e) {
|
||||
log.error('site-config', 'GET failed', { err: String(e) });
|
||||
error(500, 'Failed to load site config');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /api/site-config
|
||||
* Admin only — updates decoration + logoAnimation + eventLabel.
|
||||
*/
|
||||
export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body) error(400, 'Invalid JSON body');
|
||||
|
||||
const validDecorations = ['snow', 'sakura', 'fireflies', 'leaves', 'stars', null];
|
||||
if (body.decoration !== undefined && !validDecorations.includes(body.decoration)) {
|
||||
error(400, `Invalid decoration — must be one of: ${validDecorations.filter(Boolean).join(', ')}, or null`);
|
||||
}
|
||||
|
||||
const validLogoAnimations = ['none', 'glow', 'rainbow', 'pulse', 'shimmer'];
|
||||
if (body.logoAnimation !== undefined && !validLogoAnimations.includes(body.logoAnimation)) {
|
||||
error(400, `Invalid logoAnimation — must be one of: ${validLogoAnimations.join(', ')}`);
|
||||
}
|
||||
|
||||
if (body.eventLabel !== undefined && typeof body.eventLabel !== 'string') {
|
||||
error(400, 'eventLabel must be a string');
|
||||
}
|
||||
|
||||
try {
|
||||
await saveSiteConfig({
|
||||
decoration: body.decoration ?? null,
|
||||
logoAnimation: body.logoAnimation ?? 'none',
|
||||
eventLabel: typeof body.eventLabel === 'string' ? body.eventLabel.slice(0, 64) : '',
|
||||
});
|
||||
return json({ ok: true });
|
||||
} catch (e) {
|
||||
log.error('site-config', 'PUT failed', { err: String(e) });
|
||||
error(500, 'Failed to save site config');
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user