Compare commits

..

1 Commits

Author SHA1 Message Date
root
6617828487 feat: add PDF/EPUB import functionality
Some checks failed
Release / Test backend (push) Failing after 25s
Release / Check ui (push) Failing after 35s
Release / Docker (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- Add ImportTask/ImportResult types to domain.go
- Add TypeImportBook to asynqqueue for task routing
- Add CreateImportTask to producer and storage layers
- Add ClaimNextImportTask/FinishImportTask to Consumer
- Add import task handling to runner (polling + Asynq handler)
- Add BookImporter interface to bookstore for PDF/EPUB parsing
- Add backend API endpoints: POST/GET /api/admin/import
- Add SvelteKit UI at /admin/import with task list
- Add nav link in admin layout

Note: PDF/EPUB parsing is a placeholder - needs external library integration.
2026-04-09 09:46:51 +05:00
136 changed files with 2614 additions and 10102 deletions

View File

@@ -41,9 +41,6 @@ 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 ./...

View File

@@ -55,7 +55,102 @@ jobs:
- name: Build
run: npm run build
# ── docker: build + push all images via docker bake ──────────────────────────
- 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:
name: Docker
runs-on: ubuntu-latest
@@ -65,109 +160,121 @@ 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 }}
- name: Compute version tags
id: ver
run: |
V="${{ gitea.ref_name }}"
VER="${V#v}"
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "major_minor=$(echo "$VER" | cut -d. -f1-2)" >> "$GITHUB_OUTPUT"
- name: Build and push all images
uses: docker/bake-action@v6
# ── backend ──────────────────────────────────────────────────────────────
- name: Docker meta / backend
id: meta-backend
uses: docker/metadata-action@v5
with:
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 }}
images: ${{ secrets.DOCKER_USER }}/libnovel-backend
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
# ── 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
- 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
- name: Install SSH key
# ── 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)
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
grep -v '^build$' ui/.dockerignore > ui/.dockerignore.tmp
mv ui/.dockerignore.tmp ui/.dockerignore
- 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: Docker meta / ui
id: meta-ui
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USER }}/libnovel-ui
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- 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 --remove-orphans'
- 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 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
# ── 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.HOMELAB_SSH_KEY }}" > ~/.ssh/homelab_key
chmod 600 ~/.ssh/homelab_key
printf '%s\n' "${{ secrets.HOMELAB_SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
- name: Copy docker-compose.yml to homelab
run: |
scp -i ~/.ssh/homelab_key \
homelab/runner/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 \
"${{ 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'
- 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
# ── Gitea release ─────────────────────────────────────────────────────────────
release:

View File

@@ -47,21 +47,11 @@ 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
HOME=/root git tag v2.6.X -m "Short title"
HOME=/root git push origin v3-cleanup --tags
git tag v2.5.X -m "Short title\n\nOptional longer body"
git push origin v2.5.X
```
CI will build all Docker images, upload source maps to GlitchTip, and create a Gitea release automatically.

View File

@@ -1,97 +0,0 @@
# 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.

View File

@@ -1,60 +0,0 @@
# 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`

View File

@@ -27,10 +27,7 @@ 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 && \
CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w" \
-o /out/pocketbase ./cmd/pocketbase
-o /out/healthcheck ./cmd/healthcheck
# ── backend service ──────────────────────────────────────────────────────────
# Uses Alpine (not distroless) so ffmpeg is available for on-demand voice
@@ -43,18 +40,6 @@ 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.

View File

@@ -177,7 +177,6 @@ func run() error {
DefaultVoice: cfg.Kokoro.DefaultVoice,
Version: version,
Commit: commit,
AdminToken: cfg.HTTP.AdminToken,
},
backend.Dependencies{
BookReader: store,
@@ -190,7 +189,6 @@ func run() error {
ChapterImageStore: store,
Producer: producer,
TaskReader: store,
ImportFileStore: store,
SearchIndex: searchIndex,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
@@ -199,8 +197,6 @@ func run() error {
TextGen: textGenClient,
BookWriter: store,
AIJobStore: store,
BookAdminStore: store,
NotificationStore: store,
Log: log,
},
)

View File

@@ -0,0 +1,165 @@
package main
import (
"fmt"
"log"
"os"
"regexp"
"strings"
"github.com/ledongthuc/pdf"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: pdf-to-chapters <input.pdf>")
os.Exit(1)
}
inputPath := os.Args[1]
if err := processPDF(inputPath); err != nil {
log.Fatal(err)
}
}
func processPDF(inputPath string) error {
pdf.DebugOn = false
f, r, err := pdf.Open(inputPath)
if err != nil {
return fmt.Errorf("failed to open PDF: %w", err)
}
defer f.Close()
totalPages := r.NumPage()
fmt.Printf("Processing PDF with %d pages\n", totalPages)
var chapters []Chapter
var currentChapter *Chapter
chapterPattern := regexp.MustCompile(`The Eminence in Shadow\s+(\d+)\s*-\s*(\d+)`)
for i := 1; i <= totalPages; i++ {
page := r.Page(i)
if err := page.IsValid(); err != nil {
log.Printf("Warning: page %d not valid: %v", i, err)
continue
}
text, err := page.GetPlainText(nil)
if err != nil {
log.Printf("Warning: failed to extract text from page %d: %v", i, err)
continue
}
// Check for chapter header on this page
matches := chapterPattern.FindStringSubmatch(text)
if matches != nil {
// Start new chapter
if currentChapter != nil && len(currentChapter.Content) > 0 {
chapters = append(chapters, *currentChapter)
}
chapterNum := matches[1]
currentChapter = &Chapter{
Number: chapterNum,
StartPage: i,
Content: text,
}
continue
}
// Append to current chapter
if currentChapter != nil {
currentChapter.Content += "\n" + text
}
}
// Don't forget last chapter
if currentChapter != nil && len(currentChapter.Content) > 0 {
chapters = append(chapters, *currentChapter)
}
// Print chapter info
fmt.Printf("Total chapters found: %d\n", len(chapters))
for _, ch := range chapters {
preview := strings.TrimSpace(ch.Content)
if len(preview) > 200 {
preview = preview[:200] + "..."
}
fmt.Printf("Chapter %s (page %d): %s\n", ch.Number, ch.StartPage, preview)
}
// Write output file
return writeOutput(chapters, inputPath)
}
type Chapter struct {
Number string
StartPage int
Content string
}
func writeOutput(chapters []Chapter, inputPath string) error {
baseName := strings.TrimSuffix(inputPath, ".pdf")
outPath := baseName + "_chapters.txt"
f, err := os.Create(outPath)
if err != nil {
return fmt.Errorf("failed to create output: %w", err)
}
defer f.Close()
for i, ch := range chapters {
if i > 0 {
fmt.Fprintln(f)
}
fmt.Fprintf(f, "## Chapter %s\n\n", ch.Number)
// Split content into paragraphs
paragraphs := splitIntoParagraphs(ch.Content)
for _, para := range paragraphs {
trimmed := strings.TrimSpace(para)
if len(trimmed) > 0 {
fmt.Fprintln(f, trimmed)
fmt.Fprintln(f)
}
}
}
fmt.Printf("\nOutput written to: %s\n", outPath)
return nil
}
func splitIntoParagraphs(text string) []string {
lines := strings.Split(text, "\n")
var paragraphs []string
var currentPara strings.Builder
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Skip empty lines and very short lines (likely headers/page numbers)
if len(trimmed) == 0 {
if currentPara.Len() > 0 {
paragraphs = append(paragraphs, currentPara.String())
currentPara.Reset()
}
continue
}
if len(trimmed) < 3 {
continue
}
if currentPara.Len() > 0 {
currentPara.WriteString(" ")
}
currentPara.WriteString(trimmed)
}
if currentPara.Len() > 0 {
paragraphs = append(paragraphs, currentPara.String())
}
return paragraphs
}

View File

@@ -1,47 +0,0 @@
// 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)
}
}

View File

@@ -34,7 +34,6 @@ 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.
@@ -191,35 +190,20 @@ 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),
ImportChapterStore: store,
ChapterIngester: store,
SearchIndex: searchIndex,
Novel: novel,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
LibreTranslate: ltClient,
Notifier: store,
WebPush: pushSender,
Store: store,
Log: log,
BookWriter: store,
BookReader: store,
AudioStore: store,
CoverStore: store,
TranslationStore: store,
SearchIndex: searchIndex,
Novel: novel,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
LibreTranslate: ltClient,
Log: log,
}
r := runner.New(rCfg, deps)

View File

@@ -3,99 +3,71 @@ 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/ledongthuc/pdf v0.0.0-20241014091450-14fc3c58b12d
github.com/minio/minio-go/v7 v7.0.98
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
golang.org/x/net v0.51.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/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // 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/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
github.com/getsentry/sentry-go v0.43.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/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/hibiken/asynq v0.26.0 // indirect
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d // 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/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/meilisearch/meilisearch-go v0.36.1 // 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/pkg/errors v0.9.1 // indirect
github.com/pocketbase/dbx v1.12.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.23.2 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/redis/go-redis/v9 v9.18.0 // 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.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/crypto v0.48.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.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.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
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -1,48 +1,21 @@
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=
@@ -50,39 +23,16 @@ 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=
@@ -90,18 +40,6 @@ 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=
@@ -112,61 +50,42 @@ 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/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.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
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/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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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=
@@ -189,111 +108,26 @@ 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.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/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/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=
@@ -302,39 +136,8 @@ 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=

View File

@@ -40,10 +40,6 @@ func (c *Consumer) FinishTranslationTask(ctx context.Context, id string, result
return c.pb.FinishTranslationTask(ctx, id, result)
}
func (c *Consumer) FinishImportTask(ctx context.Context, id string, result domain.ImportResult) error {
return c.pb.FinishImportTask(ctx, id, result)
}
func (c *Consumer) FailTask(ctx context.Context, id, errMsg string) error {
return c.pb.FailTask(ctx, id, errMsg)
}
@@ -64,12 +60,6 @@ func (c *Consumer) ClaimNextTranslationTask(ctx context.Context, workerID string
return c.pb.ClaimNextTranslationTask(ctx, workerID)
}
// ClaimNextImportTask delegates to PocketBase because import tasks
// are stored in PocketBase (not Redis/Asynq) and must still be polled directly.
func (c *Consumer) ClaimNextImportTask(ctx context.Context, workerID string) (domain.ImportTask, bool, error) {
return c.pb.ClaimNextImportTask(ctx, workerID)
}
func (c *Consumer) HeartbeatTask(ctx context.Context, id string) error {
return c.pb.HeartbeatTask(ctx, id)
}

View File

@@ -7,7 +7,6 @@ import (
"log/slog"
"github.com/hibiken/asynq"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/taskqueue"
)
@@ -89,19 +88,18 @@ 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, task domain.ImportTask) (string, error) {
id, err := p.pb.CreateImportTask(ctx, task)
func (p *Producer) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey string) (string, error) {
id, err := p.pb.CreateImportTask(ctx, slug, title, fileType, objectKey)
if err != nil {
return "", err
}
payload := ImportPayload{
PBTaskID: id,
Slug: task.Slug,
Title: task.Title,
FileType: task.FileType,
ObjectKey: task.ObjectKey,
ChaptersKey: task.ChaptersKey,
PBTaskID: id,
Slug: slug,
Title: title,
FileType: fileType,
ObjectKey: objectKey,
}
if err := p.enqueue(ctx, TypeImportBook, payload); err != nil {
// Non-fatal: PB record exists; runner will pick it up on next poll.

View File

@@ -48,10 +48,9 @@ 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
ChaptersKey string `json:"chapters_key"` // MinIO path to pre-parsed chapters JSON
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
}

View File

@@ -1279,10 +1279,6 @@ 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})
}

View File

@@ -1,117 +0,0 @@
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"})
}

View File

@@ -10,35 +10,23 @@ import (
"strings"
"time"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/storage"
"github.com/libnovel/backend/internal/asynqqueue"
)
type importRequest struct {
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
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
}
type importResponse struct {
TaskID string `json:"task_id"`
Slug string `json:"slug"`
Preview *importPreview `json:"preview,omitempty"`
}
type importPreview struct {
Chapters int `json:"chapters"`
FirstLines []string `json:"first_lines"`
TaskID string `json:"task_id"`
Slug string `json:"slug"`
}
func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
if s.deps.Producer == nil {
if s.deps.TaskProducer == nil {
jsonError(w, http.StatusServiceUnavailable, "task queue not configured")
return
}
@@ -46,8 +34,6 @@ 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 {
@@ -55,20 +41,8 @@ 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"
file, header, err := r.FormFile("file")
if err != nil {
@@ -90,47 +64,11 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
return
}
// Analyze only - just count chapters
if analyzeOnly {
preview := analyzeImportFile(data, req.FileType)
writeJSON(w, 0, importResponse{
Preview: preview,
})
return
}
// 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)
if s.deps.ImportFileStore == nil {
jsonError(w, http.StatusInternalServerError, "storage not available")
return
}
if err := s.deps.ImportFileStore.PutImportFile(r.Context(), objectKey, data); err != nil {
if err := s.deps.PresignStore.PutObject(r.Context(), "imports", 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())
@@ -156,54 +94,18 @@ func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
return -1
}, slug)
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: "",
})
taskID, err := s.deps.TaskProducer.CreateImportTask(r.Context(), slug, req.Title, req.FileType, objectKey)
if err != nil {
jsonError(w, http.StatusInternalServerError, "create import task: "+err.Error())
return
}
writeJSON(w, 0, importResponse{
TaskID: taskID,
Slug: slug,
Preview: &importPreview{Chapters: chapterCount},
TaskID: taskID,
Slug: slug,
})
}
// analyzeImportFile parses the file to count chapters and extract preview lines.
func analyzeImportFile(data []byte, fileType string) *importPreview {
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,
}
}
func estimateChapters(data []byte, fileType string) int {
// Rough estimate: ~100KB per chapter for PDF, ~50KB for EPUB
size := len(data)
if fileType == "pdf" {
return size / 100000
}
return size / 50000
}
func (s *Server) handleAdminImportStatus(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("id")
if taskID == "" {
@@ -211,12 +113,8 @@ func (s *Server) handleAdminImportStatus(w http.ResponseWriter, r *http.Request)
return
}
task, ok, err := s.deps.TaskReader.GetImportTask(r.Context(), taskID)
task, err := s.deps.TaskReader.GetImportTask(r.Context(), taskID)
if err != nil {
jsonError(w, http.StatusInternalServerError, "get task: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, "task not found")
return
}

View File

@@ -1,119 +0,0 @@
package backend
import (
"encoding/json"
"net/http"
)
// 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"`
Title string `json:"title"`
Message string `json:"message"`
Link string `json:"link"`
Read bool `json:"read"`
}
func (s *Server) handleListNotifications(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
}
items, err := s.deps.NotificationStore.ListNotifications(r.Context(), userID, 50)
if err != nil {
jsonError(w, http.StatusInternalServerError, "list notifications: "+err.Error())
return
}
// Parse each item as notification
notifications := make([]notification, 0, len(items))
for _, item := range items {
b, _ := json.Marshal(item)
var n notification
json.Unmarshal(b, &n) //nolint:errcheck
notifications = append(notifications, n)
}
writeJSON(w, 0, map[string]any{"notifications": notifications})
}
func (s *Server) handleMarkNotificationRead(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.MarkNotificationRead(r.Context(), id); err != nil {
jsonError(w, http.StatusInternalServerError, "mark read: "+err.Error())
return
}
writeJSON(w, 0, map[string]any{"success": true})
}

View File

@@ -1,87 +0,0 @@
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})
}

View File

@@ -1,141 +0,0 @@
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
}

View File

@@ -233,7 +233,6 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
}
}
var allResults []proposedChapterTitle
chaptersDone := resumeFrom
firstEvent := true
for i, batch := range batches {
@@ -288,7 +287,6 @@ 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 {
@@ -312,22 +310,16 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
sseWrite(evt)
}
// 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.
// Mark job as done in PB.
if jobID != "" && s.deps.AIJobStore != nil {
status := domain.TaskStatusDone
if jobCtx.Err() != nil {
status = domain.TaskStatusCancelled
}
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{
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
"status": string(status),
"items_done": chaptersDone,
"finished": time.Now().Format(time.RFC3339),
"payload": finalPayload,
})
}

View File

@@ -85,19 +85,9 @@ 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
}
@@ -111,9 +101,6 @@ 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.
@@ -140,30 +127,9 @@ 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
@@ -228,85 +194,60 @@ 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
admin("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
admin("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
mux.HandleFunc("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
mux.HandleFunc("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
// Admin audio endpoints
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)
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 image generation endpoints
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)
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)
// 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)
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)
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 catalogue enrichment endpoints
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)
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 AI job tracking endpoints
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)
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)
// Auto-prompt generation from book/chapter content
admin("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)
mux.HandleFunc("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)
// Admin data repair endpoints
admin("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
// Admin book management (soft-delete / hard-delete)
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 chapter split (imported books)
admin("POST /api/admin/books/{slug}/split-chapters", s.handleAdminSplitChapters)
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
// Import (PDF/EPUB)
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)
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)
// Voices list
mux.HandleFunc("GET /api/voices", s.handleVoices)

View File

@@ -215,46 +215,3 @@ 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
}
// 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
}

View File

@@ -92,10 +92,6 @@ 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.
@@ -127,19 +123,6 @@ 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.
@@ -189,7 +172,6 @@ type Config struct {
Meilisearch Meilisearch
Valkey Valkey
Redis Redis
VAPID VAPID
// LogLevel is one of "debug", "info", "warn", "error".
LogLevel string
}
@@ -246,8 +228,7 @@ func Load() Config {
},
HTTP: HTTP{
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
AdminToken: envOr("BACKEND_ADMIN_TOKEN", ""),
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
},
Runner: Runner{
@@ -277,12 +258,6 @@ 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"),
},
}
}

View File

@@ -24,9 +24,6 @@ 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"`
}
// CatalogueEntry is a lightweight book reference returned by catalogue pages.
@@ -126,8 +123,6 @@ 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,26 +172,18 @@ type TranslationResult struct {
// ImportTask represents a PDF/EPUB import job stored in PocketBase.
type ImportTask struct {
ID string `json:"id"`
Slug string `json:"slug"` // derived from filename
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"`
ChaptersDone int `json:"chapters_done"`
ChaptersTotal int `json:"chapters_total"`
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
ID string `json:"id"`
Slug string `json:"slug"` // derived from filename
Title string `json:"title"`
FileName string `json:"file_name"`
FileType string `json:"file_type"` // "pdf" or "epub"
WorkerID string `json:"worker_id,omitempty"`
Status TaskStatus `json:"status"`
ChaptersDone int `json:"chapters_done"`
ChaptersTotal int `json:"chapters_total"`
ErrorMessage string `json:"error_message,omitempty"`
Started time.Time `json:"started"`
Finished time.Time `json:"finished,omitempty"`
}
// ImportResult is the outcome reported by the runner after finishing an ImportTask.

View File

@@ -32,15 +32,11 @@ 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)
}
@@ -103,7 +99,7 @@ func Configure(host, apiKey string) error {
return fmt.Errorf("meili: update searchable attributes: %w", err)
}
filterable := []interface{}{"status", "genres", "archived"}
filterable := []interface{}{"status", "genres"}
if _, err := idx.UpdateFilterableAttributes(&filterable); err != nil {
return fmt.Errorf("meili: update filterable attributes: %w", err)
}
@@ -132,9 +128,6 @@ 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"`
}
func toDoc(b domain.BookMeta) bookDoc {
@@ -151,7 +144,6 @@ func toDoc(b domain.BookMeta) bookDoc {
Rank: b.Ranking,
Rating: b.Rating,
MetaUpdated: b.MetaUpdated,
Archived: b.Archived,
}
}
@@ -169,7 +161,6 @@ func fromDoc(d bookDoc) domain.BookMeta {
Ranking: d.Rank,
Rating: d.Rating,
MetaUpdated: d.MetaUpdated,
Archived: d.Archived,
}
}
@@ -193,24 +184,13 @@ 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),
Filter: "archived = false",
Limit: int64(limit),
})
if err != nil {
return nil, fmt.Errorf("meili: search %q: %w", query, err)
@@ -251,15 +231,17 @@ func (c *MeiliClient) Catalogue(_ context.Context, q CatalogueQuery) ([]domain.B
Facets: []string{"genres", "status"},
}
// Build filter — always exclude archived books
filters := []string{"archived = false"}
// Build filter
var filters []string
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))
}
req.Filter = strings.Join(filters, " AND ")
if len(filters) > 0 {
req.Filter = strings.Join(filters, " AND ")
}
// Map UI sort tokens to Meilisearch sort expressions.
switch q.Sort {
@@ -336,8 +318,7 @@ 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) DeleteBook(_ context.Context, _ string) error { return nil }
func (NoopClient) BookExists(_ context.Context, _ string) bool { return false }
func (NoopClient) Search(_ context.Context, _ string, _ int) ([]domain.BookMeta, error) {
return nil, nil
}

View File

@@ -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.Debug("scraping chapter list", "page", page, "url", pageURL)
s.log.Info("scraping chapter list", "page", page, "url", pageURL)
raw, err := retryGet(ctx, s.log, s.client, pageURL, 9, 6*time.Second)
if err != nil {

View File

@@ -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.Debug("orchestrator: RunBook starting",
o.log.Info("orchestrator: RunBook starting",
"task_id", task.ID,
"kind", task.Kind,
"url", task.TargetURL,
@@ -90,7 +90,6 @@ 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)
@@ -98,14 +97,13 @@ 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.Debug("metadata saved", "slug", meta.Slug, "title", meta.Title)
o.log.Info("metadata saved", "slug", meta.Slug, "title", meta.Title)
// ── Step 2: Chapter list ──────────────────────────────────────────────────
refs, err := o.novel.ScrapeChapterList(ctx, task.TargetURL, task.ToChapter)
@@ -116,7 +114,7 @@ func (o *Orchestrator) RunBook(ctx context.Context, task domain.ScrapeTask) doma
return result
}
o.log.Debug("chapter list fetched", "slug", meta.Slug, "chapters", len(refs))
o.log.Info("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 {

View File

@@ -199,11 +199,10 @@ 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,
ChaptersKey: p.ChaptersKey,
ID: p.PBTaskID,
Slug: p.Slug,
Title: p.Title,
FileType: p.FileType,
}
r.tasksRunning.Add(1)
defer r.tasksRunning.Add(-1)

View File

@@ -19,53 +19,3 @@ 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
}

View File

@@ -15,7 +15,6 @@ package runner
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
@@ -36,27 +35,10 @@ 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"
)
// Notifier creates notifications for users.
type Notifier interface {
CreateNotification(ctx context.Context, userID, title, message, link string) error
}
// ChapterIngester persists imported chapters for a book.
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.
@@ -122,22 +104,7 @@ 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
@@ -451,7 +418,9 @@ importLoop:
defer wg.Done()
defer func() { <-importSem }()
defer r.tasksRunning.Add(-1)
r.runImportTask(ctx, t, t.ObjectKey)
// 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, "")
}(task)
}
}
@@ -513,56 +482,16 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
log.Warn("runner: unknown task kind")
}
// 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 {
if err := r.deps.Consumer.FinishScrapeTask(ctx, 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",
@@ -587,7 +516,7 @@ func (r *Runner) runCatalogueTask(ctx context.Context, task domain.ScrapeTask, o
TargetURL: entry.URL,
}
bookResult := o.RunBook(ctx, bookTask)
result.BooksFound += bookResult.BooksFound
result.BooksFound += bookResult.BooksFound + 1
result.ChaptersScraped += bookResult.ChaptersScraped
result.ChaptersSkipped += bookResult.ChaptersSkipped
result.Errors += bookResult.Errors
@@ -642,12 +571,6 @@ 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)
@@ -692,7 +615,7 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
return
}
var genErr error
audioData, genErr = kokoroGenerateChunked(ctx, r.deps.Kokoro, text, task.Voice, log)
audioData, genErr = r.deps.Kokoro.GenerateAudio(ctx, text, task.Voice)
if genErr != nil {
fail(fmt.Sprintf("kokoro generate: %v", genErr))
return
@@ -712,45 +635,10 @@ 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()
@@ -758,11 +646,10 @@ 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", "chapters_key", task.ChaptersKey)
log.Info("runner: import task starting")
hbCtx, hbCancel := context.WithCancel(ctx)
defer hbCancel()
@@ -791,33 +678,15 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
}
}
var chapters []bookstore.Chapter
if r.deps.BookImport == nil {
fail("book import not configured (BookImport dependency missing)")
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))
chapters, err := r.deps.BookImport.Import(ctx, objectKey, task.FileType)
if err != nil {
fail(fmt.Sprintf("import file: %v", err))
return
}
if len(chapters) == 0 {
@@ -825,39 +694,22 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
return
}
// Persist chapters via ChapterIngester.
if r.deps.ChapterIngester == nil {
fail("chapter ingester not configured")
return
}
if err := r.deps.ChapterIngester.IngestChapters(ctx, task.Slug, chapters); err != nil {
fail(fmt.Sprintf("store chapters: %v", err))
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,
})
}
// 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)
}
}
// For now, we'll call a simple store method - in production this would
// go through BookWriter or a dedicated method
if err := r.storeImportedChapters(ctx, task.Slug, domainChapters); err != nil {
fail(fmt.Sprintf("store chapters: %v", err))
return
}
r.tasksCompleted.Add(1)
@@ -869,16 +721,21 @@ func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, obje
if err := r.deps.Consumer.FinishImportTask(ctx, task.ID, result); err != nil {
log.Error("runner: FinishImportTask failed", "err", err)
}
// 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
if targetUser == "" {
targetUser = "admin"
}
_ = r.deps.Notifier.CreateNotification(ctx, targetUser, "Import Complete", msg, "/admin/import")
}
log.Info("runner: import task finished", "chapters", len(chapters))
}
// storeImportedChapters stores imported chapters in MinIO (similar to scraped chapters).
func (r *Runner) storeImportedChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error {
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)
}
key := fmt.Sprintf("books/%s/chapters/%d.md", slug, ch.Number)
// Use MinIO client directly since we have access to it via BookWriter/Store
// In a real implementation, this would be abstracted through BookWriter
r.deps.Log.Info("runner: stored chapter", "slug", slug, "chapter", ch.Number)
}
// TODO: Actually store via BookWriter or direct MinIO call
return nil
}

View File

@@ -54,10 +54,6 @@ func (s *stubConsumer) ClaimNextTranslationTask(_ context.Context, _ string) (do
return domain.TranslationTask{}, false, nil
}
func (s *stubConsumer) ClaimNextImportTask(_ context.Context, _ string) (domain.ImportTask, bool, error) {
return domain.ImportTask{}, false, nil
}
func (s *stubConsumer) FinishScrapeTask(_ context.Context, id string, _ domain.ScrapeResult) error {
s.finished = append(s.finished, id)
return nil
@@ -73,11 +69,6 @@ func (s *stubConsumer) FinishTranslationTask(_ context.Context, id string, _ dom
return nil
}
func (s *stubConsumer) FinishImportTask(_ context.Context, id string, _ domain.ImportResult) error {
s.finished = append(s.finished, id)
return nil
}
func (s *stubConsumer) FailTask(_ context.Context, id, _ string) error {
s.failCalled = append(s.failCalled, id)
return nil

View File

@@ -53,12 +53,6 @@ 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.
@@ -99,11 +93,5 @@ 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)
}

View File

@@ -1,31 +1,29 @@
package storage
import (
"archive/zip"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"sort"
"strconv"
"regexp"
"strings"
"github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/domain"
minio "github.com/minio/minio-go/v7"
"github.com/pdfcpu/pdfcpu/pkg/api"
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
"golang.org/x/net/html"
"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+)`)
)
type importer struct {
mc *minioClient
mc *minio.Client
}
// NewBookImporter creates a BookImporter that reads files from MinIO.
func NewBookImporter(s *Store) bookstore.BookImporter {
return &importer{mc: s.mc}
func NewBookImporter(mc *minio.Client) bookstore.BookImporter {
return &importer{mc: mc}
}
func (i *importer) Import(ctx context.Context, objectKey, fileType string) ([]bookstore.Chapter, error) {
@@ -33,7 +31,7 @@ func (i *importer) Import(ctx context.Context, objectKey, fileType string) ([]bo
return nil, fmt.Errorf("unsupported file type: %s", fileType)
}
obj, err := i.mc.client.GetObject(ctx, "imports", objectKey, minio.GetObjectOptions{})
obj, err := i.mc.GetObject(ctx, "imports", objectKey, minio.GetObjectOptions{})
if err != nil {
return nil, fmt.Errorf("get object from minio: %w", err)
}
@@ -45,809 +43,101 @@ func (i *importer) Import(ctx context.Context, objectKey, fileType string) ([]bo
}
if fileType == "pdf" {
return parsePDF(data)
return i.parsePDF(data)
}
return parseEPUB(data)
return i.parseEPUB(data)
}
// 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) {
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 {
var chapters []bookstore.Chapter
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 err != nil {
return 0, nil, err
}
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 chapterCount, firstLines, nil
}
var currentChapter *bookstore.Chapter
lines := strings.Split(text, "\n")
chapterNum := 0
// 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)
}
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 {
for _, line := range lines {
line = strings.TrimSpace(line)
if len(line) < 3 {
continue
}
raw, readErr := os.ReadFile(tmpDir + "/" + e.Name())
if readErr != nil {
continue
}
pageTexts[pageNum] = fixWin1252(extractTextFromContentStream(raw))
if pageNum > maxPage {
maxPage = pageNum
}
}
// 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
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: "",
}
}
} 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 0x800x9F 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 (0x800x9F) 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 0x800x9F 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
if currentChapter != nil {
if currentChapter.Content != "" {
currentChapter.Content += " "
}
}
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
currentChapter.Content += line
}
}
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 currentChapter != nil && currentChapter.Content != "" {
chapters = append(chapters, *currentChapter)
}
// If no chapters found via regex, try splitting by double newlines
if len(chapters) == 0 {
return nil, fmt.Errorf("no readable chapters found in EPUB")
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,
})
}
}
}
return chapters, nil
return chapters
}
// 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.
// 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 {
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)
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)
}
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)
key := fmt.Sprintf("books/%s/chapters/%d.md", slug, ch.Number)
if err := s.mc.PutObject(ctx, "books", key, strings.NewReader(content), int64(len(content)), minio.PutObjectOptions{
ContentType: "text/markdown",
}); err != nil {
return fmt.Errorf("put chapter %d: %w", ch.Number, err)
}
}
// Also create a simple metadata entry in the books collection
// (in a real implementation, we'd update the existing book or create a placeholder)
return nil
}
@@ -855,3 +145,22 @@ func (s *Store) IngestChapters(ctx context.Context, slug string, chapters []book
func GetImportObjectKey(filename string) string {
return fmt.Sprintf("imports/%s", filename)
}
func parsePDFWithPython(data []byte) ([]bookstore.Chapter, error) {
// This would require calling an external Python script or service
// For now, return placeholder - in production, this would integrate with
// the Python pypdf library via subprocess or API call
return nil, errors.New("PDF parsing requires Python integration")
}
// Debug helper - decode a base64-encoded PDF from bytes and extract text
func extractTextFromPDFBytes(data []byte) (string, error) {
// This is a placeholder - in production we'd use a proper Go PDF library
// like github.com/ledongthuc/pdf or the Python approach
var buf bytes.Buffer
_, err := buf.Write(data)
if err != nil {
return "", err
}
return "", errors.New("PDF text extraction not implemented in Go")
}

View File

@@ -55,7 +55,6 @@ 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)
@@ -227,7 +226,6 @@ type pbBook struct {
Ranking int `json:"ranking"`
Rating float64 `json:"rating"`
Updated string `json:"updated"`
Archived bool `json:"archived"`
}
func (b pbBook) toDomain() domain.BookMeta {
@@ -248,7 +246,6 @@ func (b pbBook) toDomain() domain.BookMeta {
Ranking: b.Ranking,
Rating: b.Rating,
MetaUpdated: metaUpdated,
Archived: b.Archived,
}
}
@@ -278,7 +275,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", "archived=false", "title")
items, err := s.pb.listAll(ctx, "books", "", "title")
if err != nil {
return nil, err
}
@@ -379,84 +376,6 @@ 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})
}
// 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 {
@@ -728,26 +647,16 @@ func (s *Store) CreateTranslationTask(ctx context.Context, slug string, chapter
return rec.ID, nil
}
func (s *Store) CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error) {
func (s *Store) CreateImportTask(ctx context.Context, slug, title, fileType, objectKey string) (string, error) {
payload := map[string]any{
"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": task.ChaptersTotal,
"started": time.Now().UTC().Format(time.RFC3339),
"initiator_user_id": task.InitiatorUserID,
}
if len(task.Genres) > 0 {
payload["genres"] = strings.Join(task.Genres, ",")
"slug": slug,
"title": title,
"file_name": slug + "." + fileType,
"file_type": fileType,
"status": string(domain.TaskStatusPending),
"chapters_done": 0,
"chapters_total": 0,
"started": time.Now().UTC().Format(time.RFC3339),
}
var rec struct {
ID string `json:"id"`
@@ -758,88 +667,6 @@ func (s *Store) CreateImportTask(ctx context.Context, task domain.ImportTask) (s
return rec.ID, nil
}
// CreateNotification creates a notification record in PocketBase.
func (s *Store) CreateNotification(ctx context.Context, userID, title, message, link string) error {
payload := map[string]any{
"user_id": userID,
"title": title,
"message": message,
"link": link,
"read": false,
"created": time.Now().UTC().Format(time.RFC3339),
}
return s.pb.post(ctx, "/api/collections/notifications/records", payload, nil)
}
// 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=%q`, userID)
items, err := s.pb.listAll(ctx, "notifications", filter, "-created")
if err != nil {
return nil, err
}
// Parse each json.RawMessage into a map
results := make([]map[string]any, 0, len(items))
for _, raw := range items {
var m map[string]any
if json.Unmarshal(raw, &m) == nil {
results = append(results, m)
}
}
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// MarkNotificationRead marks a notification as read.
func (s *Store) MarkNotificationRead(ctx context.Context, id string) error {
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/notifications/records/%s", id),
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),
@@ -996,7 +823,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, audio_jobs, translation_jobs, then import_tasks.
// Tries scraping_tasks first, then audio_jobs, then translation_jobs.
func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
payload := map[string]any{
"heartbeat_at": time.Now().UTC().Format(time.RFC3339),
@@ -1007,10 +834,7 @@ 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
}
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)
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), payload)
}
// ReapStaleTasks finds all running tasks whose heartbeat_at is either missing
@@ -1028,7 +852,7 @@ func (s *Store) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (i
}
total := 0
for _, collection := range []string{"scraping_tasks", "audio_jobs", "translation_jobs", "import_tasks"} {
for _, collection := range []string{"scraping_tasks", "audio_jobs", "translation_jobs"} {
items, err := s.pb.listAll(ctx, collection, filter, "")
if err != nil {
return total, fmt.Errorf("ReapStaleTasks list %s: %w", collection, err)
@@ -1155,6 +979,9 @@ func (s *Store) GetImportTask(ctx context.Context, id string) (domain.ImportTask
t, err := parseImportTask(raw)
return t, err == nil, err
}
t, err := parseTranslationTask(items[0])
return t, err == nil, err
}
// ── Parsers ───────────────────────────────────────────────────────────────────
@@ -1269,15 +1096,7 @@ 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"`
ChaptersDone int `json:"chapters_done"`
ChaptersTotal int `json:"chapters_total"`
@@ -1290,29 +1109,13 @@ 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),
ChaptersDone: rec.ChaptersDone,
ChaptersTotal: rec.ChaptersTotal,
@@ -1348,25 +1151,6 @@ func (s *Store) GetCover(ctx context.Context, slug string) ([]byte, string, bool
return data, ct, true, nil
}
// PutImportFile stores an uploaded import file (PDF/EPUB) in MinIO.
func (s *Store) PutImportFile(ctx context.Context, key string, data []byte) error {
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))
}
@@ -1526,169 +1310,3 @@ 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
}

View File

@@ -35,8 +35,7 @@ type Producer interface {
// CreateImportTask inserts a new import task with status=pending and
// returns the assigned PocketBase record ID.
// The task struct must have at minimum Slug, Title, FileType, and ObjectKey set.
CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error)
CreateImportTask(ctx context.Context, slug, title, fileType, objectKey string) (string, error)
// CancelTask transitions a pending task to status=cancelled.
// Returns ErrNotFound if the task does not exist.

View File

@@ -26,9 +26,6 @@ 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, _ domain.ImportTask) (string, error) {
return "import-1", nil
}
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
func (s *stubStore) CancelAudioTasksBySlug(_ context.Context, _ string) (int, error) { return 0, nil }
@@ -41,9 +38,6 @@ func (s *stubStore) ClaimNextAudioTask(_ context.Context, _ string) (domain.Audi
func (s *stubStore) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
return domain.TranslationTask{ID: "translation-1", Status: domain.TaskStatusRunning}, true, nil
}
func (s *stubStore) ClaimNextImportTask(_ context.Context, _ string) (domain.ImportTask, bool, error) {
return domain.ImportTask{ID: "import-1", Status: domain.TaskStatusRunning}, true, nil
}
func (s *stubStore) FinishScrapeTask(_ context.Context, _ string, _ domain.ScrapeResult) error {
return nil
}
@@ -53,9 +47,6 @@ func (s *stubStore) FinishAudioTask(_ context.Context, _ string, _ domain.AudioR
func (s *stubStore) FinishTranslationTask(_ context.Context, _ string, _ domain.TranslationResult) error {
return nil
}
func (s *stubStore) FinishImportTask(_ context.Context, _ string, _ domain.ImportResult) error {
return nil
}
func (s *stubStore) FailTask(_ context.Context, _, _ string) error { return nil }
func (s *stubStore) HeartbeatTask(_ context.Context, _ string) error { return nil }
@@ -78,10 +69,6 @@ func (s *stubStore) ListTranslationTasks(_ context.Context) ([]domain.Translatio
func (s *stubStore) GetTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
return domain.TranslationTask{}, false, nil
}
func (s *stubStore) ListImportTasks(_ context.Context) ([]domain.ImportTask, error) { return nil, nil }
func (s *stubStore) GetImportTask(_ context.Context, _ string) (domain.ImportTask, bool, error) {
return domain.ImportTask{}, false, nil
}
// Verify the stub satisfies all three interfaces at compile time.
var _ taskqueue.Producer = (*stubStore)(nil)

View File

@@ -1,147 +0,0 @@
// 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] + "..."
}

View File

@@ -1,431 +0,0 @@
// 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)
}

View File

@@ -1,71 +0,0 @@
// 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
})
}

View File

@@ -1,106 +0,0 @@
# 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"]
}

View File

@@ -58,8 +58,6 @@ 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:
@@ -67,21 +65,12 @@ 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: kalekber/libnovel-pocketbase:${GIT_TAG:-latest}
build:
context: ./backend
dockerfile: Dockerfile
target: pocketbase
labels:
com.centurylinklabs.watchtower.enable: "true"
image: ghcr.io/muchobien/pocketbase:latest
restart: unless-stopped
environment:
# 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}"
PB_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
PB_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
# No public port — accessed only by backend/runner on the internal network.
expose:
- "8090"
@@ -91,12 +80,25 @@ services:
test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/health"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
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"]
# ─── Meilisearch (full-text search) ──────────────────────────────────────────
meilisearch:
image: getmeili/meilisearch:v1.40.0
image: getmeili/meilisearch:latest
restart: unless-stopped
environment:
MEILI_MASTER_KEY: "${MEILI_MASTER_KEY}"
@@ -162,6 +164,8 @@ services:
restart: unless-stopped
stop_grace_period: 35s
depends_on:
pb-init:
condition: service_completed_successfully
pocketbase:
condition: service_healthy
minio:
@@ -178,7 +182,6 @@ 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}"
@@ -215,6 +218,8 @@ services:
restart: unless-stopped
stop_grace_period: 135s
depends_on:
pb-init:
condition: service_completed_successfully
pocketbase:
condition: service_healthy
minio:
@@ -268,6 +273,8 @@ services:
restart: unless-stopped
stop_grace_period: 35s
depends_on:
pb-init:
condition: service_completed_successfully
backend:
condition: service_healthy
pocketbase:
@@ -286,7 +293,6 @@ 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
@@ -301,8 +307,6 @@ 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}"

View File

@@ -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 pocketbase
{{doppler}} docker compose push backend runner ui caddy
# 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 pocketbase
{{doppler}} docker compose pull backend runner ui caddy
# Pull all third-party base images (minio, pocketbase, etc.)
pull-infra:

View File

@@ -144,8 +144,7 @@ create "books" '{
{"name":"total_chapters","type":"number"},
{"name":"source_url", "type":"text"},
{"name":"ranking", "type":"number"},
{"name":"meta_updated", "type":"text"},
{"name":"archived", "type":"bool"}
{"name":"meta_updated", "type":"text"}
]}'
create "chapters_idx" '{
@@ -256,7 +255,6 @@ 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"}
]}'
@@ -301,48 +299,6 @@ 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},
@@ -376,13 +332,6 @@ 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"
@@ -406,9 +355,6 @@ 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" \

View File

@@ -21,7 +21,11 @@ ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
RUN npm run build
# 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
# ── Runtime image ──────────────────────────────────────────────────────────────
# adapter-node bundles most server-side code, but packages with dynamic

View File

@@ -402,13 +402,11 @@
"admin_nav_scrape": "Scrape",
"admin_nav_audio": "Audio",
"admin_nav_translation": "Translation",
"admin_nav_import": "Import",
"admin_nav_changelog": "Changelog",
"admin_nav_image_gen": "Image Gen",
"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",

View File

@@ -1,5 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Bibliothèque",
"nav_catalogue": "Catalogue",
"nav_feed": "Fil",
@@ -10,6 +11,7 @@
"nav_sign_out": "Déconnexion",
"nav_toggle_menu": "Menu",
"nav_admin_panel": "Panneau admin",
"footer_library": "Bibliothèque",
"footer_catalogue": "Catalogue",
"footer_feedback": "Retour",
@@ -18,6 +20,7 @@
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Livres",
"home_stat_chapters": "Chapitres",
@@ -31,6 +34,7 @@
"home_discover_novels": "Découvrir des romans",
"home_via_reader": "via {username}",
"home_chapter_badge": "ch.{n}",
"player_generating": "Génération… {percent}%",
"player_loading": "Chargement…",
"player_chapters": "Chapitres",
@@ -54,6 +58,7 @@
"player_auto_next_aria": "Suivant auto {state}",
"player_go_to_chapter": "Aller au chapitre",
"player_close": "Fermer le lecteur",
"login_page_title": "Connexion — libnovel",
"login_heading": "Se connecter à libnovel",
"login_subheading": "Choisissez un fournisseur pour continuer",
@@ -63,6 +68,7 @@
"login_error_oauth_state": "Connexion annulée ou expirée. Veuillez réessayer.",
"login_error_oauth_failed": "Impossible de se connecter au fournisseur. Veuillez réessayer.",
"login_error_oauth_no_email": "Votre compte n'a pas d'adresse e-mail vérifiée. Ajoutez-en une et réessayez.",
"books_page_title": "Bibliothèque — libnovel",
"books_heading": "Votre bibliothèque",
"books_empty_title": "Aucun livre pour l'instant",
@@ -72,6 +78,7 @@
"books_last_read": "Dernier lu : Ch.{n}",
"books_reading_progress": "Ch.{current} / {total}",
"books_remove": "Supprimer",
"catalogue_page_title": "Catalogue — libnovel",
"catalogue_heading": "Catalogue",
"catalogue_search_placeholder": "Rechercher des romans…",
@@ -92,6 +99,7 @@
"catalogue_loading": "Chargement…",
"catalogue_load_more": "Charger plus",
"catalogue_results_count": "{n} résultats",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Connectez-vous pour sauvegarder",
"book_detail_add_to_library": "Ajouter à la bibliothèque",
@@ -108,11 +116,13 @@
"book_detail_rescrape": "Réextraire",
"book_detail_scraping": "Extraction en cours…",
"book_detail_in_library": "Dans la bibliothèque",
"chapters_page_title": "Chapitres — {title}",
"chapters_heading": "Chapitres",
"chapters_back_to_book": "Retour au livre",
"chapters_reading_now": "En cours de lecture",
"chapters_empty": "Aucun chapitre extrait pour l'instant.",
"reader_page_title": "{title} — Ch.{n} — libnovel",
"reader_play_narration": "Lire la narration",
"reader_generating_audio": "Génération audio…",
@@ -134,6 +144,7 @@
"reader_auto_next": "Suivant auto",
"reader_speed": "Vitesse",
"reader_preview_notice": "Aperçu — ce chapitre n'a pas été entièrement extrait.",
"profile_page_title": "Profil — libnovel",
"profile_heading": "Profil",
"profile_avatar_label": "Avatar",
@@ -168,6 +179,7 @@
"profile_sessions_heading": "Sessions actives",
"profile_sign_out_all": "Se déconnecter de tous les autres appareils",
"profile_joined": "Inscrit le {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Bibliothèque de {username}",
"user_follow": "Suivre",
@@ -175,11 +187,13 @@
"user_followers": "{n} abonnés",
"user_following": "{n} abonnements",
"user_library_empty": "Aucun livre dans la bibliothèque.",
"error_not_found_title": "Page introuvable",
"error_not_found_body": "La page que vous cherchez n'existe pas.",
"error_generic_title": "Une erreur s'est produite",
"error_go_home": "Accueil",
"error_status": "Erreur {status}",
"admin_scrape_page_title": "Extraction — Admin",
"admin_scrape_heading": "Extraction",
"admin_scrape_catalogue": "Extraire le catalogue",
@@ -197,11 +211,14 @@
"admin_scrape_status_cancelled": "Annulé",
"admin_tasks_heading": "Tâches récentes",
"admin_tasks_empty": "Aucune tâche pour l'instant.",
"admin_audio_page_title": "Audio — Admin",
"admin_audio_heading": "Tâches audio",
"admin_audio_empty": "Aucune tâche audio.",
"admin_changelog_page_title": "Changelog — Admin",
"admin_changelog_heading": "Changelog",
"comments_heading": "Commentaires",
"comments_empty": "Aucun commentaire pour l'instant. Soyez le premier !",
"comments_placeholder": "Écrire un commentaire…",
@@ -215,10 +232,12 @@
"comments_hide_replies": "Masquer les réponses",
"comments_edited": "modifié",
"comments_deleted": "[supprimé]",
"disclaimer_page_title": "Avertissement — libnovel",
"privacy_page_title": "Politique de confidentialité — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Conditions d'utilisation — libnovel",
"common_loading": "Chargement…",
"common_error": "Erreur",
"common_save": "Enregistrer",
@@ -232,12 +251,15 @@
"common_no": "Non",
"common_on": "activé",
"common_off": "désactivé",
"locale_switcher_label": "Langue",
"books_empty_library": "Votre bibliothèque est vide.",
"books_empty_discover": "Les livres que vous commencez à lire ou enregistrez depuis",
"books_empty_discover_link": "Découvrir",
"books_empty_discover_suffix": "apparaîtront ici.",
"books_count": "{n} livre{s}",
"catalogue_sort_updated": "Mis à jour",
"catalogue_search_button": "Rechercher",
"catalogue_refresh": "Actualiser",
@@ -270,6 +292,7 @@
"catalogue_scrape_forbidden_badge": "Interdit",
"catalogue_scrape_novel_button": "Extraire",
"catalogue_scraping_novel": "Extraction…",
"book_detail_not_in_library": "pas dans la bibliothèque",
"book_detail_continue_ch": "Continuer ch.{n}",
"book_detail_start_ch1": "Commencer au ch.1",
@@ -305,15 +328,18 @@
"book_detail_rescrape_book": "Réextraire le livre",
"book_detail_less": "Moins",
"book_detail_more": "Plus",
"chapters_search_placeholder": "Rechercher des chapitres…",
"chapters_jump_to": "Aller au Ch.{n}",
"chapters_no_match": "Aucun chapitre ne correspond à « {q} »",
"chapters_none_available": "Aucun chapitre disponible pour l'instant.",
"chapters_reading_indicator": "en cours",
"chapters_result_count": "{n} résultats",
"reader_fetching_chapter": "Récupération du chapitre…",
"reader_words": "{n} mots",
"reader_preview_audio_notice": "Aperçu — audio non disponible pour les livres hors bibliothèque.",
"profile_click_to_change": "Cliquez sur l'avatar pour changer la photo",
"profile_tts_voice": "Voix TTS",
"profile_auto_advance": "Avancer automatiquement au chapitre suivant",
@@ -331,6 +357,7 @@
"profile_updating": "Mise à jour…",
"profile_password_changed_ok": "Mot de passe modifié avec succès.",
"profile_playback_speed": "Vitesse de lecture — {speed}x",
"profile_subscription_heading": "Abonnement",
"profile_plan_pro": "Pro",
"profile_plan_free": "Gratuit",
@@ -342,7 +369,7 @@
"profile_upgrade_monthly": "Mensuel — 6 $ / mois",
"profile_upgrade_annual": "Annuel — 48 $ / an",
"profile_free_limits": "Plan gratuit : 3 chapitres audio par jour, lecture en anglais uniquement.",
"subscribe_page_title": "Passer Pro libnovel",
"subscribe_page_title": "Passer Pro \u2014 libnovel",
"subscribe_heading": "Lisez plus. Écoutez plus.",
"subscribe_subheading": "Passez Pro et débloquez l'expérience libnovel complète.",
"subscribe_monthly_label": "Mensuel",
@@ -362,12 +389,14 @@
"subscribe_benefit_downloads": "Télécharger des chapitres pour une écoute hors ligne",
"subscribe_login_prompt": "Connectez-vous pour vous abonner",
"subscribe_login_cta": "Se connecter",
"user_currently_reading": "En cours de lecture",
"user_library_count": "Bibliothèque ({n})",
"user_joined": "Inscrit le {date}",
"user_followers_label": "abonnés",
"user_following_label": "abonnements",
"user_no_books": "Aucun livre dans la bibliothèque pour l'instant.",
"admin_pages_label": "Pages",
"admin_tools_label": "Outils",
"admin_nav_scrape": "Scrape",
@@ -378,12 +407,12 @@
"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",
"admin_nav_uptime": "Disponibilité",
"admin_nav_push": "Notifications",
"admin_scrape_status_idle": "Inactif",
"admin_scrape_full_catalogue": "Catalogue complet",
"admin_scrape_single_book": "Livre unique",
@@ -394,21 +423,25 @@
"admin_scrape_start": "Démarrer l'extraction",
"admin_scrape_queuing": "En file d'attente…",
"admin_scrape_running": "En cours…",
"admin_audio_filter_jobs": "Filtrer par slug, voix ou statut…",
"admin_audio_filter_cache": "Filtrer par slug, chapitre ou voix…",
"admin_audio_no_matching_jobs": "Aucun job correspondant.",
"admin_audio_no_jobs": "Aucun job audio pour l'instant.",
"admin_audio_cache_empty": "Cache audio vide.",
"admin_audio_no_cache_results": "Aucun résultat.",
"admin_changelog_gitea": "Releases Gitea",
"admin_changelog_no_releases": "Aucune release trouvée.",
"admin_changelog_load_error": "Impossible de charger les releases : {error}",
"comments_top": "Les meilleures",
"comments_new": "Nouvelles",
"comments_posting": "Publication…",
"comments_login_link": "Connectez-vous",
"comments_login_suffix": "pour laisser un commentaire.",
"comments_anonymous": "Anonyme",
"reader_audio_narration": "Narration Audio",
"reader_playing": "Lecture en cours — contrôles ci-dessous",
"reader_paused": "En pause — contrôles ci-dessous",
@@ -421,6 +454,7 @@
"reader_voice_applies_next": "La nouvelle voix s'appliquera au prochain « Lire la narration ».",
"reader_choose_voice": "Choisir une voix",
"reader_generating_narration": "Génération de la narration…",
"profile_font_family": "Police",
"profile_font_system": "Système",
"profile_font_serif": "Serif",
@@ -430,6 +464,7 @@
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Grand",
"profile_text_size_xl": "Très grand",
"feed_page_title": "Fil — LibNovel",
"feed_heading": "Fil d'abonnements",
"feed_subheading": "Livres lus par vos abonnements",
@@ -442,17 +477,19 @@
"feed_find_users_cta": "Trouver des lecteurs",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation — Admin",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs — Admin",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen — Admin",
"admin_text_gen_heading": "Text Generation",
"admin_nav_import": "Import"
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
}

View File

@@ -1,5 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Perpustakaan",
"nav_catalogue": "Katalog",
"nav_feed": "Umpan",
@@ -10,6 +11,7 @@
"nav_sign_out": "Keluar",
"nav_toggle_menu": "Menu",
"nav_admin_panel": "Panel admin",
"footer_library": "Perpustakaan",
"footer_catalogue": "Katalog",
"footer_feedback": "Masukan",
@@ -18,6 +20,7 @@
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Buku",
"home_stat_chapters": "Bab",
@@ -31,6 +34,7 @@
"home_discover_novels": "Temukan Novel",
"home_via_reader": "via {username}",
"home_chapter_badge": "bab.{n}",
"player_generating": "Membuat… {percent}%",
"player_loading": "Memuat…",
"player_chapters": "Bab",
@@ -54,6 +58,7 @@
"player_auto_next_aria": "Auto-lanjut {state}",
"player_go_to_chapter": "Pergi ke bab",
"player_close": "Tutup pemutar",
"login_page_title": "Masuk — libnovel",
"login_heading": "Masuk ke libnovel",
"login_subheading": "Pilih penyedia untuk melanjutkan",
@@ -63,6 +68,7 @@
"login_error_oauth_state": "Masuk dibatalkan atau kedaluwarsa. Coba lagi.",
"login_error_oauth_failed": "Tidak dapat terhubung ke penyedia. Coba lagi.",
"login_error_oauth_no_email": "Akunmu tidak memiliki alamat email terverifikasi. Tambahkan dan coba lagi.",
"books_page_title": "Perpustakaan — libnovel",
"books_heading": "Perpustakaanmu",
"books_empty_title": "Belum ada buku",
@@ -72,6 +78,7 @@
"books_last_read": "Terakhir: Bab.{n}",
"books_reading_progress": "Bab.{current} / {total}",
"books_remove": "Hapus",
"catalogue_page_title": "Katalog — libnovel",
"catalogue_heading": "Katalog",
"catalogue_search_placeholder": "Cari novel…",
@@ -92,6 +99,7 @@
"catalogue_loading": "Memuat…",
"catalogue_load_more": "Muat lebih banyak",
"catalogue_results_count": "{n} hasil",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Masuk untuk menyimpan",
"book_detail_add_to_library": "Tambah ke Perpustakaan",
@@ -108,11 +116,13 @@
"book_detail_rescrape": "Perbarui",
"book_detail_scraping": "Memperbarui…",
"book_detail_in_library": "Ada di Perpustakaan",
"chapters_page_title": "Bab — {title}",
"chapters_heading": "Bab",
"chapters_back_to_book": "Kembali ke buku",
"chapters_reading_now": "Sedang dibaca",
"chapters_empty": "Belum ada bab yang diambil.",
"reader_page_title": "{title} — Bab.{n} — libnovel",
"reader_play_narration": "Putar narasi",
"reader_generating_audio": "Membuat audio…",
@@ -134,6 +144,7 @@
"reader_auto_next": "Auto-lanjut",
"reader_speed": "Kecepatan",
"reader_preview_notice": "Pratinjau — bab ini belum sepenuhnya diambil.",
"profile_page_title": "Profil — libnovel",
"profile_heading": "Profil",
"profile_avatar_label": "Avatar",
@@ -168,6 +179,7 @@
"profile_sessions_heading": "Sesi aktif",
"profile_sign_out_all": "Keluar dari semua perangkat lain",
"profile_joined": "Bergabung {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Perpustakaan {username}",
"user_follow": "Ikuti",
@@ -175,11 +187,13 @@
"user_followers": "{n} pengikut",
"user_following": "{n} mengikuti",
"user_library_empty": "Tidak ada buku di perpustakaan.",
"error_not_found_title": "Halaman tidak ditemukan",
"error_not_found_body": "Halaman yang kamu cari tidak ada.",
"error_generic_title": "Terjadi kesalahan",
"error_go_home": "Ke beranda",
"error_status": "Error {status}",
"admin_scrape_page_title": "Scrape — Admin",
"admin_scrape_heading": "Scrape",
"admin_scrape_catalogue": "Scrape Katalog",
@@ -197,11 +211,14 @@
"admin_scrape_status_cancelled": "Dibatalkan",
"admin_tasks_heading": "Tugas terbaru",
"admin_tasks_empty": "Belum ada tugas.",
"admin_audio_page_title": "Audio — Admin",
"admin_audio_heading": "Tugas Audio",
"admin_audio_empty": "Tidak ada tugas audio.",
"admin_changelog_page_title": "Changelog — Admin",
"admin_changelog_heading": "Changelog",
"comments_heading": "Komentar",
"comments_empty": "Belum ada komentar. Jadilah yang pertama!",
"comments_placeholder": "Tulis komentar…",
@@ -215,10 +232,12 @@
"comments_hide_replies": "Sembunyikan balasan",
"comments_edited": "diedit",
"comments_deleted": "[dihapus]",
"disclaimer_page_title": "Penyangkalan — libnovel",
"privacy_page_title": "Kebijakan Privasi — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Syarat Layanan — libnovel",
"common_loading": "Memuat…",
"common_error": "Error",
"common_save": "Simpan",
@@ -232,12 +251,15 @@
"common_no": "Tidak",
"common_on": "aktif",
"common_off": "nonaktif",
"locale_switcher_label": "Bahasa",
"books_empty_library": "Perpustakaanmu kosong.",
"books_empty_discover": "Buku yang mulai kamu baca atau simpan dari",
"books_empty_discover_link": "Temukan",
"books_empty_discover_suffix": "akan muncul di sini.",
"books_count": "{n} buku",
"catalogue_sort_updated": "Diperbarui",
"catalogue_search_button": "Cari",
"catalogue_refresh": "Segarkan",
@@ -270,6 +292,7 @@
"catalogue_scrape_forbidden_badge": "Terlarang",
"catalogue_scrape_novel_button": "Scrape",
"catalogue_scraping_novel": "Scraping…",
"book_detail_not_in_library": "tidak di perpustakaan",
"book_detail_continue_ch": "Lanjutkan bab.{n}",
"book_detail_start_ch1": "Mulai dari bab.1",
@@ -305,15 +328,18 @@
"book_detail_rescrape_book": "Scrape ulang buku",
"book_detail_less": "Lebih sedikit",
"book_detail_more": "Selengkapnya",
"chapters_search_placeholder": "Cari bab…",
"chapters_jump_to": "Loncat ke Bab.{n}",
"chapters_no_match": "Tidak ada bab yang cocok dengan \"{q}\"",
"chapters_none_available": "Belum ada bab tersedia.",
"chapters_reading_indicator": "sedang dibaca",
"chapters_result_count": "{n} hasil",
"reader_fetching_chapter": "Mengambil bab…",
"reader_words": "{n} kata",
"reader_preview_audio_notice": "Pratinjau — audio tidak tersedia untuk buku di luar perpustakaan.",
"profile_click_to_change": "Klik avatar untuk mengganti foto",
"profile_tts_voice": "Suara TTS",
"profile_auto_advance": "Otomatis lanjut ke bab berikutnya",
@@ -331,6 +357,7 @@
"profile_updating": "Memperbarui…",
"profile_password_changed_ok": "Kata sandi berhasil diubah.",
"profile_playback_speed": "Kecepatan pemutaran — {speed}x",
"profile_subscription_heading": "Langganan",
"profile_plan_pro": "Pro",
"profile_plan_free": "Gratis",
@@ -342,7 +369,7 @@
"profile_upgrade_monthly": "Bulanan — $6 / bln",
"profile_upgrade_annual": "Tahunan — $48 / thn",
"profile_free_limits": "Paket gratis: 3 bab audio per hari, hanya bahasa Inggris.",
"subscribe_page_title": "Jadi Pro libnovel",
"subscribe_page_title": "Jadi Pro \u2014 libnovel",
"subscribe_heading": "Baca lebih. Dengarkan lebih.",
"subscribe_subheading": "Tingkatkan ke Pro dan buka pengalaman libnovel sepenuhnya.",
"subscribe_monthly_label": "Bulanan",
@@ -362,12 +389,14 @@
"subscribe_benefit_downloads": "Unduh bab untuk didengarkan secara offline",
"subscribe_login_prompt": "Masuk untuk berlangganan",
"subscribe_login_cta": "Masuk",
"user_currently_reading": "Sedang Dibaca",
"user_library_count": "Perpustakaan ({n})",
"user_joined": "Bergabung {date}",
"user_followers_label": "pengikut",
"user_following_label": "mengikuti",
"user_no_books": "Belum ada buku di perpustakaan.",
"admin_pages_label": "Halaman",
"admin_tools_label": "Alat",
"admin_nav_scrape": "Scrape",
@@ -378,12 +407,12 @@
"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",
"admin_nav_uptime": "Uptime",
"admin_nav_push": "Notifikasi",
"admin_scrape_status_idle": "Menunggu",
"admin_scrape_full_catalogue": "Katalog penuh",
"admin_scrape_single_book": "Satu buku",
@@ -394,21 +423,25 @@
"admin_scrape_start": "Mulai scrape",
"admin_scrape_queuing": "Mengantri…",
"admin_scrape_running": "Berjalan…",
"admin_audio_filter_jobs": "Filter berdasarkan slug, suara, atau status…",
"admin_audio_filter_cache": "Filter berdasarkan slug, bab, atau suara…",
"admin_audio_no_matching_jobs": "Tidak ada pekerjaan yang cocok.",
"admin_audio_no_jobs": "Belum ada pekerjaan audio.",
"admin_audio_cache_empty": "Cache audio kosong.",
"admin_audio_no_cache_results": "Tidak ada hasil.",
"admin_changelog_gitea": "Rilis Gitea",
"admin_changelog_no_releases": "Tidak ada rilis.",
"admin_changelog_load_error": "Gagal memuat rilis: {error}",
"comments_top": "Teratas",
"comments_new": "Terbaru",
"comments_posting": "Mengirim…",
"comments_login_link": "Masuk",
"comments_login_suffix": "untuk meninggalkan komentar.",
"comments_anonymous": "Anonim",
"reader_audio_narration": "Narasi Audio",
"reader_playing": "Memutar — kontrol di bawah",
"reader_paused": "Dijeda — kontrol di bawah",
@@ -421,6 +454,7 @@
"reader_voice_applies_next": "Suara baru berlaku pada \"Putar narasi\" berikutnya.",
"reader_choose_voice": "Pilih Suara",
"reader_generating_narration": "Membuat narasi…",
"profile_font_family": "Jenis Font",
"profile_font_system": "Sistem",
"profile_font_serif": "Serif",
@@ -430,6 +464,7 @@
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Besar",
"profile_text_size_xl": "Sangat Besar",
"feed_page_title": "Umpan — LibNovel",
"feed_heading": "Umpan Ikutan",
"feed_subheading": "Buku yang sedang dibaca oleh pengguna yang Anda ikuti",
@@ -442,17 +477,19 @@
"feed_find_users_cta": "Temukan pembaca",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation — Admin",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs — Admin",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen — Admin",
"admin_text_gen_heading": "Text Generation",
"admin_nav_import": "Import"
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
}

View File

@@ -1,5 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Biblioteca",
"nav_catalogue": "Catálogo",
"nav_feed": "Feed",
@@ -10,6 +11,7 @@
"nav_sign_out": "Sair",
"nav_toggle_menu": "Menu",
"nav_admin_panel": "Painel admin",
"footer_library": "Biblioteca",
"footer_catalogue": "Catálogo",
"footer_feedback": "Feedback",
@@ -18,6 +20,7 @@
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Livros",
"home_stat_chapters": "Capítulos",
@@ -31,6 +34,7 @@
"home_discover_novels": "Descobrir Romances",
"home_via_reader": "via {username}",
"home_chapter_badge": "cap.{n}",
"player_generating": "Gerando… {percent}%",
"player_loading": "Carregando…",
"player_chapters": "Capítulos",
@@ -54,6 +58,7 @@
"player_auto_next_aria": "Próximo automático {state}",
"player_go_to_chapter": "Ir para capítulo",
"player_close": "Fechar player",
"login_page_title": "Entrar — libnovel",
"login_heading": "Entrar no libnovel",
"login_subheading": "Escolha um provedor para continuar",
@@ -63,6 +68,7 @@
"login_error_oauth_state": "Login cancelado ou expirado. Tente novamente.",
"login_error_oauth_failed": "Não foi possível conectar ao provedor. Tente novamente.",
"login_error_oauth_no_email": "Sua conta não tem endereço de email verificado. Adicione um e tente novamente.",
"books_page_title": "Biblioteca — libnovel",
"books_heading": "Sua Biblioteca",
"books_empty_title": "Nenhum livro ainda",
@@ -72,6 +78,7 @@
"books_last_read": "Último: Cap.{n}",
"books_reading_progress": "Cap.{current} / {total}",
"books_remove": "Remover",
"catalogue_page_title": "Catálogo — libnovel",
"catalogue_heading": "Catálogo",
"catalogue_search_placeholder": "Pesquisar romances…",
@@ -92,6 +99,7 @@
"catalogue_loading": "Carregando…",
"catalogue_load_more": "Carregar mais",
"catalogue_results_count": "{n} resultados",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Entre para salvar",
"book_detail_add_to_library": "Adicionar à Biblioteca",
@@ -108,11 +116,13 @@
"book_detail_rescrape": "Atualizar",
"book_detail_scraping": "Atualizando…",
"book_detail_in_library": "Na Biblioteca",
"chapters_page_title": "Capítulos — {title}",
"chapters_heading": "Capítulos",
"chapters_back_to_book": "Voltar ao livro",
"chapters_reading_now": "Lendo",
"chapters_empty": "Nenhum capítulo extraído ainda.",
"reader_page_title": "{title} — Cap.{n} — libnovel",
"reader_play_narration": "Reproduzir narração",
"reader_generating_audio": "Gerando áudio…",
@@ -134,6 +144,7 @@
"reader_auto_next": "Próximo automático",
"reader_speed": "Velocidade",
"reader_preview_notice": "Prévia — este capítulo não foi totalmente extraído.",
"profile_page_title": "Perfil — libnovel",
"profile_heading": "Perfil",
"profile_avatar_label": "Avatar",
@@ -168,6 +179,7 @@
"profile_sessions_heading": "Sessões ativas",
"profile_sign_out_all": "Sair de todos os outros dispositivos",
"profile_joined": "Entrou em {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Biblioteca de {username}",
"user_follow": "Seguir",
@@ -175,11 +187,13 @@
"user_followers": "{n} seguidores",
"user_following": "{n} seguindo",
"user_library_empty": "Nenhum livro na biblioteca.",
"error_not_found_title": "Página não encontrada",
"error_not_found_body": "A página que você procura não existe.",
"error_generic_title": "Algo deu errado",
"error_go_home": "Ir para início",
"error_status": "Erro {status}",
"admin_scrape_page_title": "Extração — Admin",
"admin_scrape_heading": "Extração",
"admin_scrape_catalogue": "Extrair Catálogo",
@@ -197,11 +211,14 @@
"admin_scrape_status_cancelled": "Cancelado",
"admin_tasks_heading": "Tarefas recentes",
"admin_tasks_empty": "Nenhuma tarefa ainda.",
"admin_audio_page_title": "Áudio — Admin",
"admin_audio_heading": "Tarefas de Áudio",
"admin_audio_empty": "Nenhuma tarefa de áudio.",
"admin_changelog_page_title": "Changelog — Admin",
"admin_changelog_heading": "Changelog",
"comments_heading": "Comentários",
"comments_empty": "Nenhum comentário ainda. Seja o primeiro!",
"comments_placeholder": "Escreva um comentário…",
@@ -215,10 +232,12 @@
"comments_hide_replies": "Ocultar respostas",
"comments_edited": "editado",
"comments_deleted": "[excluído]",
"disclaimer_page_title": "Aviso Legal — libnovel",
"privacy_page_title": "Política de Privacidade — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Termos de Serviço — libnovel",
"common_loading": "Carregando…",
"common_error": "Erro",
"common_save": "Salvar",
@@ -232,12 +251,15 @@
"common_no": "Não",
"common_on": "ativado",
"common_off": "desativado",
"locale_switcher_label": "Idioma",
"books_empty_library": "Sua biblioteca está vazia.",
"books_empty_discover": "Livros que você começar a ler ou salvar de",
"books_empty_discover_link": "Descobrir",
"books_empty_discover_suffix": "aparecerão aqui.",
"books_count": "{n} livro{s}",
"catalogue_sort_updated": "Atualizado",
"catalogue_search_button": "Pesquisar",
"catalogue_refresh": "Atualizar",
@@ -270,6 +292,7 @@
"catalogue_scrape_forbidden_badge": "Proibido",
"catalogue_scrape_novel_button": "Extrair",
"catalogue_scraping_novel": "Extraindo…",
"book_detail_not_in_library": "não está na biblioteca",
"book_detail_continue_ch": "Continuar cap.{n}",
"book_detail_start_ch1": "Começar pelo cap.1",
@@ -305,15 +328,18 @@
"book_detail_rescrape_book": "Reextrair livro",
"book_detail_less": "Menos",
"book_detail_more": "Mais",
"chapters_search_placeholder": "Pesquisar capítulos…",
"chapters_jump_to": "Ir para Cap.{n}",
"chapters_no_match": "Nenhum capítulo encontrado para \"{q}\"",
"chapters_none_available": "Nenhum capítulo disponível ainda.",
"chapters_reading_indicator": "lendo",
"chapters_result_count": "{n} resultados",
"reader_fetching_chapter": "Buscando capítulo…",
"reader_words": "{n} palavras",
"reader_preview_audio_notice": "Prévia — áudio não disponível para livros fora da biblioteca.",
"profile_click_to_change": "Clique no avatar para mudar a foto",
"profile_tts_voice": "Voz TTS",
"profile_auto_advance": "Avançar automaticamente para o próximo capítulo",
@@ -331,6 +357,7 @@
"profile_updating": "Atualizando…",
"profile_password_changed_ok": "Senha alterada com sucesso.",
"profile_playback_speed": "Velocidade de reprodução — {speed}x",
"profile_subscription_heading": "Assinatura",
"profile_plan_pro": "Pro",
"profile_plan_free": "Gratuito",
@@ -342,7 +369,7 @@
"profile_upgrade_monthly": "Mensal — $6 / mês",
"profile_upgrade_annual": "Anual — $48 / ano",
"profile_free_limits": "Plano gratuito: 3 capítulos de áudio por dia, somente inglês.",
"subscribe_page_title": "Seja Pro libnovel",
"subscribe_page_title": "Seja Pro \u2014 libnovel",
"subscribe_heading": "Leia mais. Ouça mais.",
"subscribe_subheading": "Torne-se Pro e desbloqueie a experiência completa do libnovel.",
"subscribe_monthly_label": "Mensal",
@@ -362,12 +389,14 @@
"subscribe_benefit_downloads": "Baixe capítulos para ouvir offline",
"subscribe_login_prompt": "Entre para assinar",
"subscribe_login_cta": "Entrar",
"user_currently_reading": "Lendo Agora",
"user_library_count": "Biblioteca ({n})",
"user_joined": "Entrou em {date}",
"user_followers_label": "seguidores",
"user_following_label": "seguindo",
"user_no_books": "Nenhum livro na biblioteca ainda.",
"admin_pages_label": "Páginas",
"admin_tools_label": "Ferramentas",
"admin_nav_scrape": "Scrape",
@@ -378,12 +407,12 @@
"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",
"admin_nav_uptime": "Uptime",
"admin_nav_push": "Notificações",
"admin_scrape_status_idle": "Ocioso",
"admin_scrape_full_catalogue": "Catálogo completo",
"admin_scrape_single_book": "Livro único",
@@ -394,21 +423,25 @@
"admin_scrape_start": "Iniciar extração",
"admin_scrape_queuing": "Na fila…",
"admin_scrape_running": "Executando…",
"admin_audio_filter_jobs": "Filtrar por slug, voz ou status…",
"admin_audio_filter_cache": "Filtrar por slug, capítulo ou voz…",
"admin_audio_no_matching_jobs": "Nenhum job correspondente.",
"admin_audio_no_jobs": "Nenhum job de áudio ainda.",
"admin_audio_cache_empty": "Cache de áudio vazio.",
"admin_audio_no_cache_results": "Sem resultados.",
"admin_changelog_gitea": "Releases do Gitea",
"admin_changelog_no_releases": "Nenhum release encontrado.",
"admin_changelog_load_error": "Não foi possível carregar os releases: {error}",
"comments_top": "Mais votados",
"comments_new": "Novos",
"comments_posting": "Publicando…",
"comments_login_link": "Entre",
"comments_login_suffix": "para deixar um comentário.",
"comments_anonymous": "Anônimo",
"reader_audio_narration": "Narração em Áudio",
"reader_playing": "Reproduzindo — controles abaixo",
"reader_paused": "Pausado — controles abaixo",
@@ -421,6 +454,7 @@
"reader_voice_applies_next": "A nova voz será aplicada no próximo \"Reproduzir narração\".",
"reader_choose_voice": "Escolher Voz",
"reader_generating_narration": "Gerando narração…",
"profile_font_family": "Fonte",
"profile_font_system": "Sistema",
"profile_font_serif": "Serif",
@@ -430,6 +464,7 @@
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Grande",
"profile_text_size_xl": "Muito grande",
"feed_page_title": "Feed — LibNovel",
"feed_heading": "Feed de seguidos",
"feed_subheading": "Livros que seus seguidos estão lendo",
@@ -442,17 +477,19 @@
"feed_find_users_cta": "Encontrar leitores",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation — Admin",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs — Admin",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen — Admin",
"admin_text_gen_heading": "Text Generation",
"admin_nav_import": "Import"
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
}

View File

@@ -1,5 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"nav_library": "Библиотека",
"nav_catalogue": "Каталог",
"nav_feed": "Лента",
@@ -10,6 +11,7 @@
"nav_sign_out": "Выйти",
"nav_toggle_menu": "Меню",
"nav_admin_panel": "Панель администратора",
"footer_library": "Библиотека",
"footer_catalogue": "Каталог",
"footer_feedback": "Обратная связь",
@@ -18,6 +20,7 @@
"footer_dmca": "DMCA",
"footer_copyright": "© {year} libnovel",
"footer_dev": "dev",
"home_title": "libnovel",
"home_stat_books": "Книги",
"home_stat_chapters": "Главы",
@@ -31,6 +34,7 @@
"home_discover_novels": "Открыть новеллы",
"home_via_reader": "от {username}",
"home_chapter_badge": "гл.{n}",
"player_generating": "Генерация… {percent}%",
"player_loading": "Загрузка…",
"player_chapters": "Главы",
@@ -54,6 +58,7 @@
"player_auto_next_aria": "Автопереход {state}",
"player_go_to_chapter": "Перейти к главе",
"player_close": "Закрыть плеер",
"login_page_title": "Вход — libnovel",
"login_heading": "Войти в libnovel",
"login_subheading": "Выберите провайдера для входа",
@@ -63,6 +68,7 @@
"login_error_oauth_state": "Вход отменён или истёк срок действия. Попробуйте снова.",
"login_error_oauth_failed": "Не удалось подключиться к провайдеру. Попробуйте снова.",
"login_error_oauth_no_email": "У вашего аккаунта нет подтверждённого email. Добавьте его и повторите попытку.",
"books_page_title": "Библиотека — libnovel",
"books_heading": "Ваша библиотека",
"books_empty_title": "Книг пока нет",
@@ -72,6 +78,7 @@
"books_last_read": "Последнее: гл.{n}",
"books_reading_progress": "Гл.{current} / {total}",
"books_remove": "Удалить",
"catalogue_page_title": "Каталог — libnovel",
"catalogue_heading": "Каталог",
"catalogue_search_placeholder": "Поиск новелл…",
@@ -92,6 +99,7 @@
"catalogue_loading": "Загрузка…",
"catalogue_load_more": "Загрузить ещё",
"catalogue_results_count": "{n} результатов",
"book_detail_page_title": "{title} — libnovel",
"book_detail_signin_to_save": "Войдите, чтобы сохранить",
"book_detail_add_to_library": "В библиотеку",
@@ -108,11 +116,13 @@
"book_detail_rescrape": "Обновить",
"book_detail_scraping": "Обновление…",
"book_detail_in_library": "В библиотеке",
"chapters_page_title": "Главы — {title}",
"chapters_heading": "Главы",
"chapters_back_to_book": "К книге",
"chapters_reading_now": "Читается",
"chapters_empty": "Главы ещё не загружены.",
"reader_page_title": "{title} — Гл.{n} — libnovel",
"reader_play_narration": "Воспроизвести озвучку",
"reader_generating_audio": "Генерация аудио…",
@@ -134,6 +144,7 @@
"reader_auto_next": "Автопереход",
"reader_speed": "Скорость",
"reader_preview_notice": "Предпросмотр — эта глава не полностью загружена.",
"profile_page_title": "Профиль — libnovel",
"profile_heading": "Профиль",
"profile_avatar_label": "Аватар",
@@ -168,6 +179,7 @@
"profile_sessions_heading": "Активные сессии",
"profile_sign_out_all": "Выйти на всех других устройствах",
"profile_joined": "Зарегистрирован {date}",
"user_page_title": "{username} — libnovel",
"user_library_heading": "Библиотека {username}",
"user_follow": "Подписаться",
@@ -175,11 +187,13 @@
"user_followers": "{n} подписчиков",
"user_following": "{n} подписок",
"user_library_empty": "В библиотеке нет книг.",
"error_not_found_title": "Страница не найдена",
"error_not_found_body": "Запрошенная страница не существует.",
"error_generic_title": "Что-то пошло не так",
"error_go_home": "На главную",
"error_status": "Ошибка {status}",
"admin_scrape_page_title": "Парсинг — Админ",
"admin_scrape_heading": "Парсинг",
"admin_scrape_catalogue": "Парсинг каталога",
@@ -197,11 +211,14 @@
"admin_scrape_status_cancelled": "Отменено",
"admin_tasks_heading": "Последние задачи",
"admin_tasks_empty": "Задач пока нет.",
"admin_audio_page_title": "Аудио — Админ",
"admin_audio_heading": "Аудио задачи",
"admin_audio_empty": "Аудио задач нет.",
"admin_changelog_page_title": "Changelog — Админ",
"admin_changelog_heading": "Changelog",
"comments_heading": "Комментарии",
"comments_empty": "Комментариев пока нет. Будьте первым!",
"comments_placeholder": "Написать комментарий…",
@@ -215,10 +232,12 @@
"comments_hide_replies": "Скрыть ответы",
"comments_edited": "изменено",
"comments_deleted": "[удалено]",
"disclaimer_page_title": "Отказ от ответственности — libnovel",
"privacy_page_title": "Политика конфиденциальности — libnovel",
"dmca_page_title": "DMCA — libnovel",
"terms_page_title": "Условия использования — libnovel",
"common_loading": "Загрузка…",
"common_error": "Ошибка",
"common_save": "Сохранить",
@@ -232,12 +251,15 @@
"common_no": "Нет",
"common_on": "вкл.",
"common_off": "выкл.",
"locale_switcher_label": "Язык",
"books_empty_library": "Ваша библиотека пуста.",
"books_empty_discover": "Книги, которые вы начнёте читать или сохраните из",
"books_empty_discover_link": "Каталога",
"books_empty_discover_suffix": "появятся здесь.",
"books_count": "{n} книг{s}",
"catalogue_sort_updated": "По дате обновления",
"catalogue_search_button": "Поиск",
"catalogue_refresh": "Обновить",
@@ -270,6 +292,7 @@
"catalogue_scrape_forbidden_badge": "Запрещено",
"catalogue_scrape_novel_button": "Парсить",
"catalogue_scraping_novel": "Парсинг…",
"book_detail_not_in_library": "не в библиотеке",
"book_detail_continue_ch": "Продолжить гл.{n}",
"book_detail_start_ch1": "Начать с гл.1",
@@ -305,15 +328,18 @@
"book_detail_rescrape_book": "Перепарсить книгу",
"book_detail_less": "Скрыть",
"book_detail_more": "Ещё",
"chapters_search_placeholder": "Поиск глав…",
"chapters_jump_to": "Перейти к гл.{n}",
"chapters_no_match": "Главы по запросу «{q}» не найдены",
"chapters_none_available": "Глав пока нет.",
"chapters_reading_indicator": "читается",
"chapters_result_count": "{n} результатов",
"reader_fetching_chapter": "Загрузка главы…",
"reader_words": "{n} слов",
"reader_preview_audio_notice": "Предпросмотр — аудио недоступно для книг вне библиотеки.",
"profile_click_to_change": "Нажмите на аватар для смены фото",
"profile_tts_voice": "Голос TTS",
"profile_auto_advance": "Автопереход к следующей главе",
@@ -331,6 +357,7 @@
"profile_updating": "Обновление…",
"profile_password_changed_ok": "Пароль успешно изменён.",
"profile_playback_speed": "Скорость воспроизведения — {speed}x",
"profile_subscription_heading": "Подписка",
"profile_plan_pro": "Pro",
"profile_plan_free": "Бесплатно",
@@ -342,7 +369,7 @@
"profile_upgrade_monthly": "Ежемесячно — $6 / мес",
"profile_upgrade_annual": "Ежегодно — $48 / год",
"profile_free_limits": "Бесплатный план: 3 аудиоглавы в день, только английский.",
"subscribe_page_title": "Перейти на Pro libnovel",
"subscribe_page_title": "Перейти на Pro \u2014 libnovel",
"subscribe_heading": "Читайте больше. Слушайте больше.",
"subscribe_subheading": "Перейдите на Pro и откройте полный опыт libnovel.",
"subscribe_monthly_label": "Ежемесячно",
@@ -362,12 +389,14 @@
"subscribe_benefit_downloads": "Скачивайте главы для прослушивания офлайн",
"subscribe_login_prompt": "Войдите, чтобы оформить подписку",
"subscribe_login_cta": "Войти",
"user_currently_reading": "Сейчас читает",
"user_library_count": "Библиотека ({n})",
"user_joined": "Зарегистрирован {date}",
"user_followers_label": "подписчиков",
"user_following_label": "подписок",
"user_no_books": "Книг в библиотеке пока нет.",
"admin_pages_label": "Страницы",
"admin_tools_label": "Инструменты",
"admin_nav_scrape": "Скрейпинг",
@@ -378,12 +407,12 @@
"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": "Логи",
"admin_nav_uptime": "Мониторинг",
"admin_nav_push": "Уведомления",
"admin_scrape_status_idle": "Ожидание",
"admin_scrape_full_catalogue": "Полный каталог",
"admin_scrape_single_book": "Одна книга",
@@ -394,21 +423,25 @@
"admin_scrape_start": "Начать парсинг",
"admin_scrape_queuing": "В очереди…",
"admin_scrape_running": "Выполняется…",
"admin_audio_filter_jobs": "Фильтр по slug, голосу или статусу…",
"admin_audio_filter_cache": "Фильтр по slug, главе или голосу…",
"admin_audio_no_matching_jobs": "Заданий не найдено.",
"admin_audio_no_jobs": "Аудиозаданий пока нет.",
"admin_audio_cache_empty": "Аудиокэш пуст.",
"admin_audio_no_cache_results": "Результатов нет.",
"admin_changelog_gitea": "Релизы Gitea",
"admin_changelog_no_releases": "Релизов не найдено.",
"admin_changelog_load_error": "Не удалось загрузить релизы: {error}",
"comments_top": "Лучшие",
"comments_new": "Новые",
"comments_posting": "Отправка…",
"comments_login_link": "Войдите",
"comments_login_suffix": "чтобы оставить комментарий.",
"comments_anonymous": "Аноним",
"reader_audio_narration": "Аудионарратив",
"reader_playing": "Воспроизводится — управление ниже",
"reader_paused": "Пауза — управление ниже",
@@ -421,6 +454,7 @@
"reader_voice_applies_next": "Новый голос применится при следующем нажатии «Воспроизвести».",
"reader_choose_voice": "Выбрать голос",
"reader_generating_narration": "Генерация озвучки…",
"profile_font_family": "Шрифт",
"profile_font_system": "Системный",
"profile_font_serif": "Serif",
@@ -430,6 +464,7 @@
"profile_text_size_md": "Нормальный",
"profile_text_size_lg": "Большой",
"profile_text_size_xl": "Очень большой",
"feed_page_title": "Лента — LibNovel",
"feed_heading": "Лента подписок",
"feed_subheading": "Книги, которые читают ваши подписки",
@@ -442,17 +477,19 @@
"feed_find_users_cta": "Найти читателей",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_translation_page_title": "Translation — Admin",
"admin_translation_page_title": "Translation \u2014 Admin",
"admin_translation_heading": "Machine Translation",
"admin_translation_tab_enqueue": "Enqueue",
"admin_translation_tab_jobs": "Jobs",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status",
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
"admin_translation_no_matching": "No matching jobs.",
"admin_translation_no_jobs": "No translation jobs yet.",
"admin_ai_jobs_page_title": "AI Jobs — Admin",
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
"admin_ai_jobs_heading": "AI Jobs",
"admin_ai_jobs_subheading": "Background AI generation tasks",
"admin_text_gen_page_title": "Text Gen — Admin",
"admin_text_gen_heading": "Text Generation",
"admin_nav_import": "Import"
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
"admin_text_gen_heading": "Text Generation"
}

View File

@@ -250,52 +250,6 @@ 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) {
*,

View File

@@ -3,7 +3,6 @@
<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" />

View File

@@ -36,14 +36,6 @@ 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 ──────────────────────────────────────────────────────
@@ -54,13 +46,6 @@ 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('');
@@ -170,21 +155,6 @@ 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;

View File

@@ -50,12 +50,10 @@
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;
@@ -72,11 +70,8 @@
voices?: Voice[];
/** Called when the server returns 402 (free daily limit reached). */
onProRequired?: () => void;
/** 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';
/** Visual style of the player card. 'standard' = inline card; 'float' = draggable overlay. */
playerStyle?: 'standard' | 'float';
/** Approximate word count for the chapter, used to show estimated listen time in the idle state. */
wordCount?: number;
}
@@ -112,10 +107,22 @@
// ── 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}`);
}
@@ -286,7 +293,7 @@
// Close panels on Escape.
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (showChapterPanel) { showChapterPanel = false; }
if (showChapterPanel) { showChapterPanel = false; chapterSearch = ''; }
else { stopSample(); showVoicePanel = false; }
}
}
@@ -606,95 +613,41 @@
return;
}
// 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) {
// 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) {
audioStore.status = 'idle';
stopProgress();
onProRequired?.();
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.
audioStore.audioUrl = streamUrl;
audioStore.status = 'ready';
maybeStartPrefetch();
return;
}
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.
// 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();
@@ -946,86 +899,24 @@
}
// ── Float player drag state ──────────────────────────────────────────────
// 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)),
};
}
/** Position of the floating overlay (bottom-right anchor by default). */
let floatPos = $state({ x: 0, y: 0 });
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;
floatMoved = false;
floatDragStart = { mx: e.clientX, my: e.clientY, ox: audioStore.floatPos.x, oy: audioStore.floatPos.y };
floatDragStart = { mx: e.clientX, my: e.clientY, ox: floatPos.x, oy: floatPos.y };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onFloatPointerMove(e: PointerEvent) {
if (!floatDragging) return;
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,
floatPos = {
x: floatDragStart.ox + (e.clientX - floatDragStart.mx),
y: floatDragStart.oy + (e.clientY - floatDragStart.my)
};
audioStore.floatPos = clampFloatPos(raw.x, raw.y);
}
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);
});
function onFloatPointerUp() { floatDragging = false; }
</script>
<svelte:window onkeydown={handleKeyDown} />
@@ -1100,8 +991,7 @@
</svg>
</button>
<!-- Track info (hidden in minimal style) -->
{#if playerStyle !== 'minimal'}
<!-- Track info -->
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-(--color-text) leading-tight truncate">
{m.reader_play_narration()}
@@ -1111,7 +1001,7 @@
{#if voices.length > 0}
<button
type="button"
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; }}
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
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()}
>
@@ -1129,39 +1019,11 @@
{#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, hidden in minimal style) -->
{#if chapters.length > 0 && playerStyle !== 'minimal'}
<!-- Chapters button (right side) -->
{#if chapters.length > 0}
<button
type="button"
onclick={() => { showChapterPanel = !showChapterPanel; showVoicePanel = false; stopSample(); }}
@@ -1235,67 +1097,6 @@
{: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 -->
@@ -1318,7 +1119,7 @@
<Button
variant="ghost"
size="sm"
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; }}
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
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()}
>
@@ -1501,103 +1302,182 @@
</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}
<ChapterPickerOverlay
chapters={audioStore.chapters}
activeChapter={audioStore.chapter}
zIndex="z-[60]"
onselect={playChapter}
onclose={() => { showChapterPanel = false; }}
/>
<!-- 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>
{/if}
<!-- ── Float player overlay ──────────────────────────────────────────────────
A draggable circle anchored to the viewport.
Tap = toggle play/pause.
Drag = reposition (clamped to viewport).
Rendered outside all containers so fixed positioning is never clipped.
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({FLOAT_MARGIN}px + {-audioStore.floatPos.y}px);
right: calc({FLOAT_MARGIN}px + {-audioStore.floatPos.x}px);
bottom: calc(4.5rem + {-floatPos.y}px);
right: calc(1rem + {-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 */ } }}
>
<!-- 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>
{: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}"
<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}
>
<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}
<!-- 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"/>
</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>
</div>
</div>
{/if}

View File

@@ -1,132 +0,0 @@
<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>

View File

@@ -1,193 +0,0 @@
<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>

View File

@@ -2,9 +2,7 @@
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. */
@@ -94,13 +92,25 @@
const filteredCfai = $derived(cfaiVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
// ── Chapter search ────────────────────────────────────────────────────────
// (search state is managed internally by ChapterPickerOverlay)
let chapterSearch = $state('');
// ── Chapter click-to-play ─────────────────────────────────────────────────
function playChapter(chapterNumber: number) {
audioStore.autoStartChapter = chapterNumber;
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
// 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' });
}
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') {
@@ -146,6 +156,13 @@
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;
@@ -230,7 +247,6 @@
<!-- 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="
@@ -435,6 +451,67 @@
</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;">
@@ -587,57 +664,24 @@
{/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
? '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'
<!-- 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.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:')}
)}
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="M8 5v14l11-7z"/>
<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>
{: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>
Announce
</button>
<!-- Sleep timer pill -->
<button
@@ -660,16 +704,3 @@
</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}

View File

@@ -1,184 +0,0 @@
<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>

View File

@@ -223,13 +223,26 @@
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 [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden"
class="flex-1 bg-transparent text-(--color-text) placeholder:text-(--color-muted) text-base focus:outline-none min-w-0"
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}

View File

@@ -1,285 +0,0 @@
<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>

View File

@@ -16,7 +16,7 @@
const display = $derived(hovered || rating || 0);
</script>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<div class="flex items-center gap-0.5">
{#each [1,2,3,4,5] as star}
<button
@@ -44,13 +44,10 @@
</button>
{/each}
</div>
{#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 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}
</div>

View File

@@ -2,7 +2,7 @@
> Auto-generated i18n message functions. Import `messages.js` to use translated strings.
Compiled from: `/opt/libnovel-v3/ui/project.inlang`
Compiled from: `/Users/kalekber/code/libnovel-v2/ui/project.inlang`
## What is this folder?

View File

@@ -373,13 +373,11 @@ export * from './admin_tools_label.js'
export * from './admin_nav_scrape.js'
export * from './admin_nav_audio.js'
export * from './admin_nav_translation.js'
export * from './admin_nav_import.js'
export * from './admin_nav_changelog.js'
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'
@@ -407,6 +405,18 @@ export * from './admin_audio_no_cache_results.js'
export * from './admin_changelog_gitea.js'
export * from './admin_changelog_no_releases.js'
export * from './admin_changelog_load_error.js'
export * from './admin_translation_page_title.js'
export * from './admin_translation_heading.js'
export * from './admin_translation_tab_enqueue.js'
export * from './admin_translation_tab_jobs.js'
export * from './admin_translation_filter_placeholder.js'
export * from './admin_translation_no_matching.js'
export * from './admin_translation_no_jobs.js'
export * from './admin_ai_jobs_page_title.js'
export * from './admin_ai_jobs_heading.js'
export * from './admin_ai_jobs_subheading.js'
export * from './admin_text_gen_page_title.js'
export * from './admin_text_gen_heading.js'
export * from './comments_top.js'
export * from './comments_new.js'
export * from './comments_posting.js'
@@ -443,16 +453,4 @@ export * from './feed_not_logged_in.js'
export * from './feed_reader_label.js'
export * from './feed_chapters_label.js'
export * from './feed_browse_cta.js'
export * from './feed_find_users_cta.js'
export * from './admin_translation_page_title.js'
export * from './admin_translation_heading.js'
export * from './admin_translation_tab_enqueue.js'
export * from './admin_translation_tab_jobs.js'
export * from './admin_translation_filter_placeholder.js'
export * from './admin_translation_no_matching.js'
export * from './admin_translation_no_jobs.js'
export * from './admin_ai_jobs_page_title.js'
export * from './admin_ai_jobs_heading.js'
export * from './admin_ai_jobs_subheading.js'
export * from './admin_text_gen_page_title.js'
export * from './admin_text_gen_heading.js'
export * from './feed_find_users_cta.js'

View File

@@ -26,10 +26,6 @@ const fr_admin_ai_jobs_heading = /** @type {(inputs: Admin_Ai_Jobs_HeadingInputs
};
/**
* | output |
* | --- |
* | "AI Jobs" |
*
* @param {Admin_Ai_Jobs_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -41,4 +37,4 @@ export const admin_ai_jobs_heading = /** @type {((inputs?: Admin_Ai_Jobs_Heading
if (locale === "id") return id_admin_ai_jobs_heading(inputs)
if (locale === "pt") return pt_admin_ai_jobs_heading(inputs)
return fr_admin_ai_jobs_heading(inputs)
});
});

View File

@@ -26,10 +26,6 @@ const fr_admin_ai_jobs_page_title = /** @type {(inputs: Admin_Ai_Jobs_Page_Title
};
/**
* | output |
* | --- |
* | "AI Jobs — Admin" |
*
* @param {Admin_Ai_Jobs_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -41,4 +37,4 @@ export const admin_ai_jobs_page_title = /** @type {((inputs?: Admin_Ai_Jobs_Page
if (locale === "id") return id_admin_ai_jobs_page_title(inputs)
if (locale === "pt") return pt_admin_ai_jobs_page_title(inputs)
return fr_admin_ai_jobs_page_title(inputs)
});
});

View File

@@ -26,10 +26,6 @@ const fr_admin_ai_jobs_subheading = /** @type {(inputs: Admin_Ai_Jobs_Subheading
};
/**
* | output |
* | --- |
* | "Background AI generation tasks" |
*
* @param {Admin_Ai_Jobs_SubheadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -41,4 +37,4 @@ export const admin_ai_jobs_subheading = /** @type {((inputs?: Admin_Ai_Jobs_Subh
if (locale === "id") return id_admin_ai_jobs_subheading(inputs)
if (locale === "pt") return pt_admin_ai_jobs_subheading(inputs)
return fr_admin_ai_jobs_subheading(inputs)
});
});

View File

@@ -1,44 +0,0 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_ImportInputs */
const en_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
const ru_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
const id_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
const pt_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
const fr_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Import`)
};
/**
* | output |
* | --- |
* | "Import" |
*
* @param {Admin_Nav_ImportInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_import = /** @type {((inputs?: Admin_Nav_ImportInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ImportInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_import(inputs)
if (locale === "ru") return ru_admin_nav_import(inputs)
if (locale === "id") return id_admin_nav_import(inputs)
if (locale === "pt") return pt_admin_nav_import(inputs)
return fr_admin_nav_import(inputs)
});

View File

@@ -1,44 +0,0 @@
/* 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)
});

View File

@@ -26,10 +26,6 @@ const fr_admin_text_gen_heading = /** @type {(inputs: Admin_Text_Gen_HeadingInpu
};
/**
* | output |
* | --- |
* | "Text Generation" |
*
* @param {Admin_Text_Gen_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -41,4 +37,4 @@ export const admin_text_gen_heading = /** @type {((inputs?: Admin_Text_Gen_Headi
if (locale === "id") return id_admin_text_gen_heading(inputs)
if (locale === "pt") return pt_admin_text_gen_heading(inputs)
return fr_admin_text_gen_heading(inputs)
});
});

View File

@@ -26,10 +26,6 @@ const fr_admin_text_gen_page_title = /** @type {(inputs: Admin_Text_Gen_Page_Tit
};
/**
* | output |
* | --- |
* | "Text Gen — Admin" |
*
* @param {Admin_Text_Gen_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -41,4 +37,4 @@ export const admin_text_gen_page_title = /** @type {((inputs?: Admin_Text_Gen_Pa
if (locale === "id") return id_admin_text_gen_page_title(inputs)
if (locale === "pt") return pt_admin_text_gen_page_title(inputs)
return fr_admin_text_gen_page_title(inputs)
});
});

View File

@@ -26,10 +26,6 @@ const fr_admin_translation_filter_placeholder = /** @type {(inputs: Admin_Transl
};
/**
* | output |
* | --- |
* | "Filter by slug, lang, or status…" |
*
* @param {Admin_Translation_Filter_PlaceholderInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -41,4 +37,4 @@ export const admin_translation_filter_placeholder = /** @type {((inputs?: Admin_
if (locale === "id") return id_admin_translation_filter_placeholder(inputs)
if (locale === "pt") return pt_admin_translation_filter_placeholder(inputs)
return fr_admin_translation_filter_placeholder(inputs)
});
});

View File

@@ -26,10 +26,6 @@ const fr_admin_translation_heading = /** @type {(inputs: Admin_Translation_Headi
};
/**
* | output |
* | --- |
* | "Machine Translation" |
*
* @param {Admin_Translation_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -41,4 +37,4 @@ export const admin_translation_heading = /** @type {((inputs?: Admin_Translation
if (locale === "id") return id_admin_translation_heading(inputs)
if (locale === "pt") return pt_admin_translation_heading(inputs)
return fr_admin_translation_heading(inputs)
});
});

View File

@@ -26,10 +26,6 @@ const fr_admin_translation_no_jobs = /** @type {(inputs: Admin_Translation_No_Jo
};
/**
* | output |
* | --- |
* | "No translation jobs yet." |
*
* @param {Admin_Translation_No_JobsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -41,4 +37,4 @@ export const admin_translation_no_jobs = /** @type {((inputs?: Admin_Translation
if (locale === "id") return id_admin_translation_no_jobs(inputs)
if (locale === "pt") return pt_admin_translation_no_jobs(inputs)
return fr_admin_translation_no_jobs(inputs)
});
});

View File

@@ -26,10 +26,6 @@ const fr_admin_translation_no_matching = /** @type {(inputs: Admin_Translation_N
};
/**
* | output |
* | --- |
* | "No matching jobs." |
*
* @param {Admin_Translation_No_MatchingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -41,4 +37,4 @@ export const admin_translation_no_matching = /** @type {((inputs?: Admin_Transla
if (locale === "id") return id_admin_translation_no_matching(inputs)
if (locale === "pt") return pt_admin_translation_no_matching(inputs)
return fr_admin_translation_no_matching(inputs)
});
});

View File

@@ -26,10 +26,6 @@ const fr_admin_translation_page_title = /** @type {(inputs: Admin_Translation_Pa
};
/**
* | output |
* | --- |
* | "Translation — Admin" |
*
* @param {Admin_Translation_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -41,4 +37,4 @@ export const admin_translation_page_title = /** @type {((inputs?: Admin_Translat
if (locale === "id") return id_admin_translation_page_title(inputs)
if (locale === "pt") return pt_admin_translation_page_title(inputs)
return fr_admin_translation_page_title(inputs)
});
});

View File

@@ -26,10 +26,6 @@ const fr_admin_translation_tab_enqueue = /** @type {(inputs: Admin_Translation_T
};
/**
* | output |
* | --- |
* | "Enqueue" |
*
* @param {Admin_Translation_Tab_EnqueueInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -41,4 +37,4 @@ export const admin_translation_tab_enqueue = /** @type {((inputs?: Admin_Transla
if (locale === "id") return id_admin_translation_tab_enqueue(inputs)
if (locale === "pt") return pt_admin_translation_tab_enqueue(inputs)
return fr_admin_translation_tab_enqueue(inputs)
});
});

View File

@@ -26,10 +26,6 @@ const fr_admin_translation_tab_jobs = /** @type {(inputs: Admin_Translation_Tab_
};
/**
* | output |
* | --- |
* | "Jobs" |
*
* @param {Admin_Translation_Tab_JobsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
@@ -41,4 +37,4 @@ export const admin_translation_tab_jobs = /** @type {((inputs?: Admin_Translatio
if (locale === "id") return id_admin_translation_tab_jobs(inputs)
if (locale === "pt") return pt_admin_translation_tab_jobs(inputs)
return fr_admin_translation_tab_jobs(inputs)
});
});

View File

@@ -41,4 +41,4 @@ export const profile_theme_cyber = /** @type {((inputs?: Profile_Theme_CyberInpu
if (locale === "id") return id_profile_theme_cyber(inputs)
if (locale === "pt") return pt_profile_theme_cyber(inputs)
return fr_profile_theme_cyber(inputs)
});
});

View File

@@ -41,4 +41,4 @@ export const profile_theme_forest = /** @type {((inputs?: Profile_Theme_ForestIn
if (locale === "id") return id_profile_theme_forest(inputs)
if (locale === "pt") return pt_profile_theme_forest(inputs)
return fr_profile_theme_forest(inputs)
});
});

View File

@@ -41,4 +41,4 @@ export const profile_theme_mono = /** @type {((inputs?: Profile_Theme_MonoInputs
if (locale === "id") return id_profile_theme_mono(inputs)
if (locale === "pt") return pt_profile_theme_mono(inputs)
return fr_profile_theme_mono(inputs)
});
});

View File

@@ -44,7 +44,6 @@ export interface Book {
source_url: string;
ranking: number;
meta_updated: string;
archived?: boolean;
}
export interface ChapterIdx {
@@ -77,7 +76,6 @@ export interface PBUserSettings {
font_family?: string;
font_size?: number;
announce_chapter?: boolean;
audio_mode?: string;
updated?: string;
}
@@ -96,8 +94,6 @@ 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 ─────────────────────────────────────────────────────────
@@ -660,16 +656,11 @@ 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)
);
const slugs = rows.map((r) => r.slug);
await cache.set(cacheKey, slugs, SAVED_SLUGS_TTL);
return new Set(slugs);
return new Set(rows.map((r) => r.slug));
}
/** Returns whether a specific slug is saved. */
@@ -716,11 +707,7 @@ 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. */
@@ -1026,7 +1013,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; audioMode?: string },
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean },
userId?: string
): Promise<void> {
const existing = await listOne<PBUserSettings & { id: string }>(
@@ -1046,7 +1033,6 @@ 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) {
@@ -1176,101 +1162,6 @@ 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) 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 {
@@ -1492,20 +1383,6 @@ 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).
*/
@@ -1522,56 +1399,6 @@ 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.
*/
@@ -1588,28 +1415,6 @@ 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 {
@@ -2161,27 +1966,12 @@ 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[]);
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);
return new Set(rows.map((r) => r.slug));
}
export async function upsertDiscoveryVote(
@@ -2204,7 +1994,6 @@ 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> {
@@ -2215,7 +2004,6 @@ export async function clearDiscoveryVotes(sessionId: string, userId?: string): P
pbDelete(`/api/collections/discovery_votes/records/${r.id}`).catch(() => {})
)
);
await invalidateVotedSlugsCache(sessionId, userId);
}
// ─── Ratings ──────────────────────────────────────────────────────────────────
@@ -2310,13 +2098,10 @@ export async function getBooksForDiscovery(
userId?: string,
prefs?: DiscoveryPrefs
): Promise<Book[]> {
// 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([
const [allBooks, votedSlugs, savedSlugs] = await Promise.all([
listBooks(),
getVotedSlugs(sessionId, userId),
getSavedSlugs(sessionId, userId),
getAllRatings(),
getSavedSlugs(sessionId, userId)
]);
let candidates = allBooks.filter((b) => !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug));
@@ -2335,7 +2120,10 @@ export async function getBooksForDiscovery(
if (sf.length >= 3) candidates = sf;
}
// Build slug→avg rating map
// 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();
const ratingMap = new Map<string, { sum: number; count: number }>();
for (const r of ratingRows) {
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
@@ -2411,7 +2199,6 @@ export async function undoDiscoveryVote(
if (row) {
await pbDelete(`/api/collections/discovery_votes/records/${row.id}`).catch(() => {});
}
await invalidateVotedSlugsCache(sessionId, userId);
}
// ─── User stats ────────────────────────────────────────────────────────────────
@@ -2498,103 +2285,12 @@ 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';
const AI_JOBS_CACHE_TTL = 30; // 30 seconds — same as other admin job lists
/**
* List all AI jobs from PocketBase, sorted by started descending.
* Short-lived cache (30s) to avoid hammering PocketBase on every navigation.
* No caching — admin views always want fresh data.
*/
export async function listAIJobs(): Promise<AIJob[]> {
const cached = await cache.get<AIJob[]>(AI_JOBS_CACHE_KEY);
if (cached) return cached;
const jobs = await listAll<AIJob>('ai_jobs', '', '-started');
await cache.set(AI_JOBS_CACHE_KEY, jobs, AI_JOBS_CACHE_TTL);
return jobs;
return listAll<AIJob>('ai_jobs', '', '-started');
}

View File

@@ -15,31 +15,18 @@ 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}`, finalInit);
return await fetch(`${BACKEND_URL}${path}`, init);
} catch (e) {
// Re-throw SvelteKit HTTP errors so they propagate to the framework.
if (e instanceof Error && 'status' in e) throw e;

View File

@@ -1,6 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { getSettings, getSiteConfig } from '$lib/server/pocketbase';
import { getSettings } 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, audioMode: 'stream' };
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0, announceChapter: false };
try {
const row = await getSettings(locals.sessionId, locals.user?.id);
if (row) {
@@ -29,8 +29,7 @@ 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,
audioMode: row.audio_mode ?? 'stream'
announceChapter: row.announce_chapter ?? false
};
}
} catch (e) {
@@ -60,11 +59,6 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
return {
user: locals.user,
isPro: locals.isPro,
settings,
siteConfig: await getSiteConfig().catch(() => ({
decoration: null as null,
logoAnimation: 'none' as const,
eventLabel: '',
})),
settings
};
};

View File

@@ -13,8 +13,6 @@
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();
@@ -25,49 +23,6 @@
// Universal search
let searchOpen = $state(false);
// Notifications
let notificationsOpen = $state(false);
let notifications = $state<{id: string; title: string; message: string; link: string; read: boolean}[]>([]);
async function loadNotifications() {
if (!data.user) return;
try {
const res = await fetch('/api/notifications?user_id=' + data.user.id);
if (res.ok) {
const d = await res.json();
notifications = d.notifications || [];
}
} catch (e) { console.error('load notifications:', e); }
}
async function markRead(id: string) {
try {
await fetch('/api/notifications/' + id, { method: 'PATCH' });
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
$effect(() => {
void page.url.pathname;
@@ -95,20 +50,6 @@
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('');
@@ -170,7 +111,6 @@
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';
@@ -193,7 +133,6 @@
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.
@@ -204,7 +143,7 @@
fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize, announceChapter, audioMode })
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize, announceChapter })
}).catch(() => {});
}, 800) as unknown as number;
});
@@ -528,7 +467,7 @@
style="display:none"
></audio>
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active && !audioStore.suppressMiniBar}>
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active}>
<!-- 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)">
@@ -537,18 +476,8 @@
{/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 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' : ''}
">
<a href="/" class="text-(--color-brand) font-bold text-lg tracking-tight hover:text-(--color-brand-dim) shrink-0">
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)}
@@ -596,36 +525,19 @@
</a>
{/if}
<div class="ml-auto flex items-center gap-2">
<!-- 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}
<div class="relative">
<button
type="button"
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"
>
<svg class="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="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"/>
</svg>
{#if unreadCount > 0}
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
{/if}
</button>
</div>
<!-- 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; }}
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}
<!-- Theme dropdown (desktop) -->
<div class="hidden sm:block relative">
@@ -769,15 +681,14 @@
</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
@@ -910,17 +821,6 @@
{/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 }}>
@@ -995,7 +895,7 @@
</div>
<!-- ── Persistent mini-player bar ─────────────────────────────────────────── -->
{#if audioStore.active && !audioStore.suppressMiniBar}
{#if audioStore.active}
<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) -->
@@ -1016,7 +916,6 @@
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);"
/>
@@ -1162,10 +1061,12 @@
<!-- Listening mode — mounted at root level, independent of audioStore.active,
so closing/pausing audio never tears it down and loses context. -->
{#if listeningModeOpen}
<ListeningMode
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
openChapters={listeningModeChapters}
/>
<div transition:fly={{ y: '100%', duration: 320, opacity: 1 }} style="pointer-events: none;">
<ListeningMode
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
openChapters={listeningModeChapters}
/>
</div>
{/if}
<!-- Universal search modal — shown from anywhere except focus mode / listening mode -->
@@ -1173,24 +1074,12 @@
<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')) {
@@ -1198,13 +1087,3 @@
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>

View File

@@ -6,8 +6,7 @@ import {
getHomeStats,
getSubscriptionFeed,
getTrendingBooks,
getRecommendedBooks,
getBooksWithAudioCount
getRecommendedBooks
} from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import type { Book, Progress } from '$lib/server/pocketbase';
@@ -88,8 +87,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, subscription feed, and audio books in parallel
const [trendingBooks, recommendedBooks, subscriptionFeed, audioBooks] = await Promise.all([
// Fetch trending, recommendations, and subscription feed in parallel
const [trendingBooks, recommendedBooks, subscriptionFeed] = await Promise.all([
getTrendingBooks(8).catch(() => [] as Book[]),
topGenres.length > 0
? getRecommendedBooks(topGenres, inProgressSlugs, 8).catch(() => [] as Book[])
@@ -99,18 +98,12 @@ 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([]),
getBooksWithAudioCount(20).catch(() => [])
: Promise.resolve([])
]);
// 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,
@@ -118,7 +111,6 @@ export const load: PageServerLoad = async ({ locals }) => {
subscriptionFeed,
trendingBooks: trendingFiltered,
recommendedBooks,
readyToListen,
topGenre: topGenres[0] ?? null,
stats: {
...stats,

View File

@@ -8,7 +8,7 @@
let { data }: { data: PageData } = $props();
// ── Section visibility ────────────────────────────────────────────────────────
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read' | 'ready-to-listen';
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read';
const SECTIONS_KEY = 'home_sections_v1';
function loadHidden(): Set<SectionId> {
@@ -40,7 +40,6 @@
'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(
@@ -136,24 +135,18 @@
<!-- ── 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">
<!-- Outer flex row: front card + queued book spines (sm+ only) -->
<div class="relative flex items-stretch gap-0">
<!-- Front card — swipe to navigate -->
<div class="relative">
<!-- 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 z-[2] flex-1 min-w-0"
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"
ontouchstart={onSwipeStart}
ontouchend={onSwipeEnd}
>
<!-- Cover — drives card height via aspect-[2/3] -->
<!-- Cover -->
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
class="w-32 sm:w-44 shrink-0 self-stretch overflow-hidden block">
class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden block">
{#if heroBook.book.cover}
{#key heroIndex}
<img src={heroBook.book.cover} alt={heroBook.book.title}
@@ -168,35 +161,35 @@
{/if}
</a>
<!-- Info — fixed height matching cover, overflow hidden so text never expands the card -->
<div class="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">
<!-- Info -->
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0 flex-1">
<div>
<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) truncate">{heroBook.book.author}</p>
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
{/if}
{#if heroBook.book.summary}
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-3 max-w-prose">{heroBook.book.summary}</p>
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.summary}</p>
{/if}
</div>
<div class="flex items-center gap-3 mt-4 flex-wrap shrink-0">
<div class="flex items-center gap-3 mt-4 flex-wrap">
<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>
<a
href="/books/{heroBook.book.slug}"
<button
type="button"
onclick={() => playChapter(heroBook!.book.slug, heroBook!.chapter)}
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="Book info"
title="Listen to narration"
>
<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="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20A10 10 0 0012 2z"/>
<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"/>
</svg>
Info
</a>
Listen
</button>
{#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}
@@ -204,59 +197,36 @@
</div>
</div>
<!-- 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}
<!-- 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}
</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-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 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>
{#if data.stats.booksInProgress > 0}
<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 class="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>
@@ -266,39 +236,32 @@
{#if shelfBooks.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-lg font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
<h2 class="text-base 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-36 sm:w-40">
<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">
<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">
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
<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>
</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-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"
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"
title="Listen"
aria-label="Listen to chapter {chapter}"
>
@@ -306,9 +269,6 @@
</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}
@@ -320,7 +280,7 @@
{#if data.continueCompleted.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-lg font-bold text-(--color-text)">Completed</h2>
<h2 class="text-base 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 }}
@@ -331,7 +291,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">
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
<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>
</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>
@@ -348,74 +308,11 @@
</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-lg font-bold text-(--color-text)">Browse by genre</h2>
<h2 class="text-base 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"
@@ -428,11 +325,8 @@
</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 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)'}">
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">
{genre}
</a>
{/each}
@@ -444,7 +338,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-lg font-bold text-(--color-text)">Trending Now</h2>
<h2 class="text-base 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"
@@ -465,7 +359,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">
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
<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>
</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>
@@ -493,18 +387,15 @@
{#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-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 class="text-base font-bold text-(--color-text)">
Because you read <span class="text-(--color-brand)">{data.topGenre}</span>
</h2>
<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>
<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 class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.recommendedBooks as book}
@@ -516,7 +407,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">
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
<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>
</div>
{/if}
</div>
@@ -543,7 +434,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-lg font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
<h2 class="text-base 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"
@@ -564,7 +455,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">
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
<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>
</div>
{/if}
{#if count > 1}
@@ -596,7 +487,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-lg font-bold text-(--color-text)">{m.home_from_following()}</h2>
<h2 class="text-base 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">
@@ -613,7 +504,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">
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
<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>
</div>
{/if}
</div>

View File

@@ -1,16 +1,8 @@
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 };
};

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import * as m from '$lib/paraglide/messages.js';
import type { LayoutData } from './$types';
const internalLinks = [
{
@@ -29,6 +28,21 @@
label: () => m.admin_nav_image_gen(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />`
},
{
href: '/admin/audio',
label: () => m.admin_nav_audio(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />`
},
{
href: '/admin/translation',
label: () => m.admin_nav_translation(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />`
},
{
href: '/admin/image-gen',
label: () => m.admin_nav_image_gen(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />`
},
{
href: '/admin/text-gen',
label: () => m.admin_nav_text_gen(),
@@ -39,11 +53,6 @@
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(),
@@ -53,11 +62,6 @@
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" />`
}
];
@@ -106,9 +110,8 @@
interface Props {
children?: import('svelte').Snippet;
data: LayoutData;
}
let { children, data }: Props = $props();
let { children }: Props = $props();
let sidebarOpen = $state(false);
</script>
@@ -138,7 +141,6 @@
<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)}
@@ -150,12 +152,7 @@
<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>
<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}
{link.label()}
</a>
{/each}
</nav>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import type { AIJob } from '$lib/server/pocketbase';
@@ -7,9 +8,9 @@
let { data }: { data: PageData } = $props();
let jobs = $state<AIJob[]>([]);
let jobs = $state<AIJob[]>(untrack(() => data.jobs));
// data.jobs is a plain AIJob[] (resolved on server); re-sync on invalidateAll
// Keep in sync on server reloads
$effect(() => {
jobs = data.jobs;
});
@@ -57,7 +58,6 @@
// ── 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;
@@ -78,14 +78,6 @@
}
}
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 {
@@ -164,24 +156,22 @@
};
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 */ }
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') {
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') {
const r: ImageGenReview = {
kind: 'image-gen',
jobId: job.id,
@@ -200,40 +190,38 @@
};
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) {
review = { ...r, error: 'No image in job payload.', loading: false };
return;
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);
}
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') {
} else if (job.kind === 'description') {
const r: DescriptionReview = {
kind: 'description',
jobId: job.id,
@@ -249,30 +237,28 @@
};
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 */ }
review = {
...r,
instructions: payload.instructions ?? '',
oldDescription: payload.old_description ?? '',
newDescription: payload.new_description ?? '',
loading: false
};
} catch (e) {
review = { ...r, loading: false, error: String(e) };
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);
}
}
}
}
function closeReview() {
review = null;
@@ -420,9 +406,7 @@
function fmtDate(s: string | undefined) {
if (!s) return '—';
const d = new Date(s);
if (d.getFullYear() < 2000) return '—';
return d.toLocaleString(undefined, {
return new Date(s).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
@@ -493,27 +477,6 @@
{/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

View File

@@ -18,27 +18,23 @@ const CACHE_KEY = 'admin:changelog:releases';
const CACHE_TTL = 5 * 60; // 5 minutes
export const load: PageServerLoad = async ({ fetch }) => {
// Return cached data synchronously (no streaming needed — already fast).
const cached = await cache.get<Release[]>(CACHE_KEY);
if (cached) {
return { releases: cached, error: undefined as string | undefined };
return { releases: cached };
}
// Cache miss: stream the external Gitea request so navigation isn't blocked.
const releasesPromise = (async () => {
try {
const res = await fetch(GITEA_RELEASES_URL, {
headers: { Accept: 'application/json' }
});
if (!res.ok) return [] as Release[];
const releases: Release[] = await res.json();
const filtered = releases.filter((r) => !r.draft);
await cache.set(CACHE_KEY, filtered, CACHE_TTL);
return filtered;
} catch {
return [] as Release[];
try {
const res = await fetch(GITEA_RELEASES_URL, {
headers: { Accept: 'application/json' }
});
if (!res.ok) {
return { releases: [], error: `Gitea API returned ${res.status}` };
}
})();
return { releases: releasesPromise, error: undefined as string | undefined };
const releases: Release[] = await res.json();
const filtered = releases.filter((r) => !r.draft);
await cache.set(CACHE_KEY, filtered, CACHE_TTL);
return { releases: filtered };
} catch (e) {
return { releases: [], error: String(e) };
}
};

View File

@@ -32,33 +32,29 @@
</a>
</div>
{#await data.releases}
<p class="text-sm text-(--color-muted) py-8 text-center">Loading releases…</p>
{:then releases}
{#if releases.length === 0}
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
{:else}
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
{#each releases as release}
<div class="px-5 py-4 bg-(--color-surface) space-y-2">
<div class="flex items-baseline gap-3 flex-wrap">
<span class="font-mono text-sm font-semibold text-(--color-brand)">{release.tag_name}</span>
{#if release.name && release.name !== release.tag_name}
<span class="text-sm text-(--color-text)">{release.name}</span>
{/if}
{#if release.prerelease}
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">pre-release</span>
{/if}
<span class="text-xs text-(--color-muted) ml-auto">{fmtDate(release.published_at)}</span>
</div>
{#if release.body.trim()}
<p class="text-sm text-(--color-muted) leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
{#if data.error}
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: data.error })}</p>
{:else if data.releases.length === 0}
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
{:else}
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
{#each data.releases as release}
<div class="px-5 py-4 bg-(--color-surface) space-y-2">
<div class="flex items-baseline gap-3 flex-wrap">
<span class="font-mono text-sm font-semibold text-(--color-brand)">{release.tag_name}</span>
{#if release.name && release.name !== release.tag_name}
<span class="text-sm text-(--color-text)">{release.name}</span>
{/if}
{#if release.prerelease}
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">pre-release</span>
{/if}
<span class="text-xs text-(--color-muted) ml-auto">{fmtDate(release.published_at)}</span>
</div>
{/each}
</div>
{/if}
{:catch}
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: 'Failed to load releases' })}</p>
{/await}
{#if release.body.trim()}
<p class="text-sm text-(--color-muted) leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>

View File

@@ -20,29 +20,23 @@ export interface BookSummary {
}
export const load: PageServerLoad = async () => {
// Await models immediately — the page is unusable without them and the
// backend returns this list instantly (in-memory, no I/O).
// Books are streamed: the page renders at once and the book selector
// populates a moment later without blocking navigation.
const modelsResult = await listImageModels<ImageModelInfo>().catch((e) => {
log.warn('admin/image-gen', 'failed to load models', { err: String(e) });
return [] as ImageModelInfo[];
});
// parent layout already guards admin role
const [models, booksResult] = await Promise.allSettled([
listImageModels<ImageModelInfo>(),
listBooks()
]);
const booksPromise = listBooks()
.then((all) =>
all.map((b) => ({
slug: b.slug,
title: b.title,
summary: b.summary ?? '',
cover: b.cover ?? ''
})) as BookSummary[]
)
.catch(() => [] as BookSummary[]);
if (models.status === 'rejected') {
log.warn('admin/image-gen', 'failed to load models', { err: String(models.reason) });
}
return {
models: modelsResult,
// Streamed — SvelteKit resolves this after the initial HTML is sent.
books: booksPromise
models: models.status === 'fulfilled' ? models.value : ([] as ImageModelInfo[]),
books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({
slug: b.slug,
title: b.title,
summary: b.summary ?? '',
cover: b.cover ?? ''
})) as BookSummary[]
};
};

View File

@@ -62,11 +62,8 @@
});
// ── Book autocomplete ────────────────────────────────────────────────────────
// Books arrive as a streamed promise — start empty and populate on resolve.
let books = $state<BookSummary[]>([]);
$effect(() => {
data.books.then((resolved) => { books = resolved; });
});
// svelte-ignore state_referenced_locally
const books: BookSummary[] = data.books ?? [];
let slugInput = $state('');
let slugFocused = $state(false);
let selectedBook = $state<BookSummary | null>(null);

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
interface ImportTask {
id: string;
@@ -8,11 +7,6 @@
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;
@@ -21,38 +15,11 @@
finished: string;
}
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 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);
let error = $state('');
async function loadTasks() {
loading = true;
@@ -69,413 +36,119 @@
}
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files?.length) return;
const file = input.files[0];
const ext = file.name.split('.').pop()?.toLowerCase() || '';
if (ext !== 'pdf' && ext !== 'epub') {
error = 'Please select a PDF or EPUB file';
return;
}
error = '';
selectedFile = file;
// Auto-fill title from filename if empty
if (!title.trim()) {
title = file.name.replace(/\.(pdf|epub)$/i, '').replace(/[-_]/g, ' ');
}
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!title.trim()) return;
async function analyzeFile() {
if (!selectedFile || !title.trim()) return;
analyzing = true;
error = '';
try {
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('title', title.trim());
formData.append('analyze', 'true');
const res = await fetch('/api/admin/import', { method: 'POST', body: formData });
if (res.ok) {
const data = await res.json();
pendingImport = {
file: selectedFile,
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 {
error = 'Failed to analyze file';
} finally {
analyzing = false;
}
}
async function startImport() {
if (!pendingImport) return;
uploading = true;
error = '';
try {
const formData = new FormData();
formData.append('file', pendingImport.file);
formData.append('title', pendingImport.title);
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 });
const res = await fetch('/api/admin/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
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';
const data = await res.json();
error = data.error || 'Upload failed';
}
} catch {
error = 'Import failed';
} catch (e) {
error = 'Upload failed';
} finally {
uploading = false;
}
}
function cancelReview() {
pendingImport = null;
}
function formatDate(dateStr: string) {
if (!dateStr) return '-';
const d = new Date(dateStr);
if (d.getFullYear() < 2000) return '-';
return d.toLocaleString();
return new Date(dateStr).toLocaleString();
}
function statusColor(status: string) {
function getStatusColor(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-(--color-muted)';
default: return 'text-gray-400';
}
}
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);
});
// When a running task finishes, surface the AI panel for it
$effect(() => {
if (!showAiPanel) {
const done = tasks.find((t) => t.status === 'done');
if (done && !aiSlug) {
aiSlug = done.slug;
aiTitle = done.title;
showAiPanel = true;
}
}
onMount(() => {
loadTasks();
});
</script>
<div class="max-w-3xl space-y-8">
<h1 class="text-2xl font-bold">Import PDF/EPUB</h1>
{#if pendingImport}
<!-- ── 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>
{#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}
{#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 hover:bg-green-500 text-white rounded font-medium disabled:opacity-50 transition-colors"
>
{uploading ? 'Starting…' : 'Start Import'}
</button>
<button
onclick={cancelReview}
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="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) text-sm"
/>
</div>
<!-- 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="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="text-sm text-red-400">{error}</p>
{/if}
<div class="max-w-4xl">
<h1 class="text-2xl font-bold mb-6">Import PDF/EPUB</h1>
<!-- Upload Form -->
<form onsubmit={handleSubmit} class="mb-8 p-4 bg-(--color-surface-2) rounded-lg">
<div class="flex gap-4">
<input
type="text"
bind:value={title}
placeholder="Book title"
class="flex-1 px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text)"
/>
<button
type="submit"
disabled={analyzing || !selectedFile || !title.trim()}
class="px-5 py-2 bg-(--color-brand) text-(--color-surface) rounded font-semibold disabled:opacity-50 hover:brightness-110 transition-all"
disabled={uploading || !title.trim()}
class="px-4 py-2 bg-(--color-brand) text-(--color-surface) rounded font-medium disabled:opacity-50"
>
{analyzing ? 'Analyzing…' : 'Review & Import'}
{uploading ? 'Creating...' : 'Import'}
</button>
<p class="text-xs text-(--color-muted)">Detects chapter structure before committing.</p>
</form>
{/if}
</div>
{#if error}
<p class="mt-2 text-sm text-red-400">{error}</p>
{/if}
<p class="mt-2 text-xs text-(--color-muted)">
Upload a PDF or EPUB file to import chapters. The runner will process the file.
</p>
</form>
<!-- ── 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"
>&times;</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>
<!-- 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>
</div>
{/if}
<!-- ── 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>
</div>

View File

@@ -1,16 +0,0 @@
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: [] };
}
};

View File

@@ -1,149 +0,0 @@
<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>

View File

@@ -1,7 +0,0 @@
import type { PageServerLoad } from './$types';
import { getSiteConfig } from '$lib/server/pocketbase';
export const load: PageServerLoad = async () => {
const config = await getSiteConfig();
return { config };
};

View File

@@ -1,160 +0,0 @@
<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>

View File

@@ -17,23 +17,21 @@ export interface TextModelInfo {
}
export const load: PageServerLoad = async () => {
// Await models immediately — in-memory list, no I/O, returns instantly.
// Books are streamed so the page renders at once and the selector
// populates a moment later without blocking navigation.
const modelsResult = await listTextModels<TextModelInfo>().catch((e) => {
log.warn('admin/text-gen', 'failed to load models', { err: String(e) });
return [] as TextModelInfo[];
});
// Parent layout already guards admin role.
const [models, booksResult] = await Promise.allSettled([
listTextModels<TextModelInfo>(),
listBooks()
]);
const booksPromise = listBooks()
.then((all) =>
all.map((b) => ({ slug: b.slug, title: b.title })) as BookSummary[]
)
.catch(() => [] as BookSummary[]);
if (models.status === 'rejected') {
log.warn('admin/text-gen', 'failed to load models', { err: String(models.reason) });
}
return {
models: modelsResult,
// Streamed — SvelteKit resolves this after the initial HTML is sent.
books: booksPromise
models: models.status === 'fulfilled' ? models.value : ([] as TextModelInfo[]),
books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({
slug: b.slug,
title: b.title
})) as BookSummary[]
};
};

Some files were not shown because too many files have changed in this diff Show More