Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71a628673d | ||
|
|
5f5aac5e3e | ||
|
|
e65883cc9e | ||
|
|
b19af1e8f3 | ||
|
|
2864c4a6c0 | ||
|
|
6d0dac256d | ||
|
|
8922111471 | ||
|
|
74e7c8e8d1 | ||
|
|
2f74b2b229 | ||
|
|
cb9598a786 | ||
|
|
fc73756308 | ||
|
|
3f436877ee | ||
|
|
812028e50d | ||
|
|
38cf1c82a1 | ||
|
|
fd0f2afe16 | ||
|
|
0f9977744a | ||
|
|
9f1c82fe05 | ||
|
|
419bb7e366 | ||
|
|
734ba68eed | ||
|
|
708f8bcd6f | ||
|
|
7009b24568 |
@@ -41,6 +41,9 @@ jobs:
|
||||
- name: Build healthcheck
|
||||
run: go build -o /dev/null ./cmd/healthcheck
|
||||
|
||||
- name: Build pocketbase
|
||||
run: go build -o /dev/null ./cmd/pocketbase
|
||||
|
||||
- name: Run tests
|
||||
run: go test -short -race -count=1 -timeout=60s ./...
|
||||
|
||||
|
||||
@@ -55,102 +55,7 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ui-build
|
||||
path: ui/build
|
||||
retention-days: 1
|
||||
|
||||
# ── ui: source map upload ─────────────────────────────────────────────────────
|
||||
# Commented out — re-enable when GlitchTip source map uploads are needed again.
|
||||
#
|
||||
# upload-sourcemaps:
|
||||
# name: Upload source maps
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: [check-ui]
|
||||
# steps:
|
||||
# - name: Compute release version (strip leading v)
|
||||
# id: ver
|
||||
# run: |
|
||||
# V="${{ gitea.ref_name }}"
|
||||
# echo "version=${V#v}" >> "$GITHUB_OUTPUT"
|
||||
#
|
||||
# - name: Download build artifacts
|
||||
# uses: actions/download-artifact@v3
|
||||
# with:
|
||||
# name: ui-build
|
||||
# path: build
|
||||
#
|
||||
# - name: Install sentry-cli
|
||||
# run: npm install -g @sentry/cli
|
||||
#
|
||||
# - name: Inject debug IDs into build artifacts
|
||||
# run: sentry-cli sourcemaps inject ./build
|
||||
# env:
|
||||
# SENTRY_URL: https://errors.libnovel.cc/
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
# SENTRY_ORG: libnovel
|
||||
# SENTRY_PROJECT: ui
|
||||
#
|
||||
# - name: Upload injected build (for docker-ui)
|
||||
# uses: actions/upload-artifact@v3
|
||||
# with:
|
||||
# name: ui-build-injected
|
||||
# path: build
|
||||
# retention-days: 1
|
||||
#
|
||||
# - name: Create GlitchTip release
|
||||
# run: sentry-cli releases new ${{ steps.ver.outputs.version }}
|
||||
# env:
|
||||
# SENTRY_URL: https://errors.libnovel.cc/
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
# SENTRY_ORG: libnovel
|
||||
# SENTRY_PROJECT: ui
|
||||
#
|
||||
# - name: Upload source maps to GlitchTip
|
||||
# run: sentry-cli sourcemaps upload ./build --release ${{ steps.ver.outputs.version }}
|
||||
# env:
|
||||
# SENTRY_URL: https://errors.libnovel.cc/
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
# SENTRY_ORG: libnovel
|
||||
# SENTRY_PROJECT: ui
|
||||
#
|
||||
# - name: Finalize GlitchTip release
|
||||
# run: sentry-cli releases finalize ${{ steps.ver.outputs.version }}
|
||||
# env:
|
||||
# SENTRY_URL: https://errors.libnovel.cc/
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
# SENTRY_ORG: libnovel
|
||||
# SENTRY_PROJECT: ui
|
||||
#
|
||||
# - name: Prune old GlitchTip releases (keep latest 10)
|
||||
# run: |
|
||||
# set -euo pipefail
|
||||
# KEEP=10
|
||||
# OLD=$(curl -sf \
|
||||
# -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
|
||||
# "$SENTRY_URL/api/0/organizations/$SENTRY_ORG/releases/?project=$SENTRY_PROJECT&per_page=100" \
|
||||
# | python3 -c "
|
||||
# import sys, json
|
||||
# releases = json.load(sys.stdin)
|
||||
# for r in releases[$KEEP:]:
|
||||
# print(r['version'])
|
||||
# " KEEP=$KEEP)
|
||||
# for ver in $OLD; do
|
||||
# echo "Deleting old release: $ver"
|
||||
# sentry-cli releases delete "$ver" || true
|
||||
# done
|
||||
# env:
|
||||
# SENTRY_URL: https://errors.libnovel.cc
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
# SENTRY_ORG: libnovel
|
||||
# SENTRY_PROJECT: ui
|
||||
|
||||
# ── docker: all images in one job (single login) ──────────────────────────────
|
||||
# backend, runner, ui, and caddy are built sequentially in one job so Docker
|
||||
# Hub only needs to be authenticated once. This also eliminates 3 redundant
|
||||
# checkout + setup-buildx + scheduler round-trips compared to separate jobs.
|
||||
# ── docker: build + push all images via docker bake ──────────────────────────
|
||||
docker:
|
||||
name: Docker
|
||||
runs-on: ubuntu-latest
|
||||
@@ -160,121 +65,72 @@ jobs:
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Single login — credential is written to ~/.docker/config.json and
|
||||
# reused by all subsequent build-push-action steps in this job.
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
# ── backend ──────────────────────────────────────────────────────────────
|
||||
- name: Docker meta / backend
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-backend
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push / backend
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: backend
|
||||
target: backend
|
||||
push: true
|
||||
tags: ${{ steps.meta-backend.outputs.tags }}
|
||||
labels: ${{ steps.meta-backend.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta-backend.outputs.version }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-backend:latest
|
||||
cache-to: type=inline
|
||||
|
||||
# ── runner ───────────────────────────────────────────────────────────────
|
||||
- name: Docker meta / runner
|
||||
id: meta-runner
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-runner
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push / runner
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: backend
|
||||
target: runner
|
||||
push: true
|
||||
tags: ${{ steps.meta-runner.outputs.tags }}
|
||||
labels: ${{ steps.meta-runner.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta-runner.outputs.version }}
|
||||
COMMIT=${{ gitea.sha }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-runner:latest
|
||||
cache-to: type=inline
|
||||
|
||||
# ── ui ───────────────────────────────────────────────────────────────────
|
||||
- name: Download ui build artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: ui-build
|
||||
path: ui/build
|
||||
|
||||
- name: Allow build/ into Docker context (override .dockerignore)
|
||||
- name: Compute version tags
|
||||
id: ver
|
||||
run: |
|
||||
grep -v '^build$' ui/.dockerignore > ui/.dockerignore.tmp
|
||||
mv ui/.dockerignore.tmp ui/.dockerignore
|
||||
V="${{ gitea.ref_name }}"
|
||||
VER="${V#v}"
|
||||
echo "version=$VER" >> "$GITHUB_OUTPUT"
|
||||
echo "major_minor=$(echo "$VER" | cut -d. -f1-2)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker meta / ui
|
||||
id: meta-ui
|
||||
uses: docker/metadata-action@v5
|
||||
- name: Build and push all images
|
||||
uses: docker/bake-action@v6
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-ui
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
files: docker-bake.hcl
|
||||
set: |
|
||||
*.output=type=image,push=true
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
MAJOR_MINOR: ${{ steps.ver.outputs.major_minor }}
|
||||
COMMIT: ${{ gitea.sha }}
|
||||
BUILD_TIME: ${{ gitea.event.head_commit.timestamp }}
|
||||
|
||||
- name: Build and push / ui
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ui
|
||||
push: true
|
||||
tags: ${{ steps.meta-ui.outputs.tags }}
|
||||
labels: ${{ steps.meta-ui.outputs.labels }}
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta-ui.outputs.version }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
BUILD_TIME=${{ gitea.event.head_commit.timestamp }}
|
||||
PREBUILT=1
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest
|
||||
cache-to: type=inline
|
||||
# ── deploy: sync docker-compose.yml + restart prod ───────────────────────────
|
||||
# Runs after all images are pushed to Docker Hub.
|
||||
# Copies the compose file from the tagged commit to the server, pulls the new
|
||||
# images, and restarts only the services whose image or config changed.
|
||||
# --remove-orphans cleans up containers no longer defined in the compose file
|
||||
# (e.g. the now-removed pb-init container).
|
||||
#
|
||||
# Required Gitea secrets:
|
||||
# PROD_HOST — prod server IP or hostname
|
||||
# PROD_USER — SSH login user (typically root)
|
||||
# PROD_SSH_KEY — private key whose public half is in authorized_keys
|
||||
# PROD_SSH_KNOWN_HOSTS — output of: ssh-keyscan -H <PROD_HOST>
|
||||
deploy:
|
||||
name: Deploy to prod
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# ── caddy ────────────────────────────────────────────────────────────────
|
||||
- name: Docker meta / caddy
|
||||
id: meta-caddy
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USER }}/libnovel-caddy
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
- name: Install SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${{ secrets.PROD_SSH_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
printf '%s\n' "${{ secrets.PROD_SSH_KNOWN_HOSTS }}" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Build and push / caddy
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: caddy
|
||||
push: true
|
||||
tags: ${{ steps.meta-caddy.outputs.tags }}
|
||||
labels: ${{ steps.meta-caddy.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-caddy:latest
|
||||
cache-to: type=inline
|
||||
- name: Copy docker-compose.yml to prod
|
||||
run: |
|
||||
scp -i ~/.ssh/deploy_key \
|
||||
docker-compose.yml \
|
||||
"${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }}:/opt/libnovel/docker-compose.yml"
|
||||
|
||||
- name: Pull new images and restart changed services
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key \
|
||||
"${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }}" \
|
||||
'set -euo pipefail
|
||||
cd /opt/libnovel
|
||||
doppler run -- docker compose pull backend runner ui caddy pocketbase
|
||||
doppler run -- docker compose up -d --remove-orphans'
|
||||
|
||||
# ── Gitea release ─────────────────────────────────────────────────────────────
|
||||
release:
|
||||
|
||||
97
CLAUDE.md
Normal file
97
CLAUDE.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Environment / Secrets
|
||||
|
||||
All project environment variables are stored in **Doppler**. When you need to access any secret or env var (e.g. API tokens, database URLs, credentials), fetch them via:
|
||||
|
||||
```bash
|
||||
doppler run -- <command> # inject all secrets into a command
|
||||
doppler secrets get SECRET_NAME # inspect a specific secret
|
||||
```
|
||||
|
||||
Never use `.env` files. Do not ask the user to provide secrets manually — they are available via Doppler.
|
||||
|
||||
## Commands
|
||||
|
||||
### Docker (via `just` — the primary way to run services)
|
||||
All services use Doppler for secrets injection. The `just` commands handle this automatically.
|
||||
|
||||
```bash
|
||||
just up # Start all services in background
|
||||
just up-fg # Start all services, stream logs
|
||||
just down # Stop all services
|
||||
just down-volumes # Full reset (destructive — removes all volumes)
|
||||
just build # Rebuild all Docker images
|
||||
just build-svc backend # Rebuild a specific service
|
||||
just restart # Stop + rebuild + start
|
||||
just logs # Tail all logs
|
||||
just log backend # Tail a specific service
|
||||
just shell backend # Open shell in running container
|
||||
just init # One-shot init: MinIO buckets, PocketBase collections, Postgres
|
||||
```
|
||||
|
||||
### Backend (Go)
|
||||
```bash
|
||||
cd backend
|
||||
go vet ./...
|
||||
go test -short -race -count=1 -timeout=60s ./...
|
||||
go test -short -race -count=1 -run TestFoo ./internal/somepackage/
|
||||
go build ./cmd/backend
|
||||
go build ./cmd/runner
|
||||
```
|
||||
|
||||
### Frontend (SvelteKit)
|
||||
```bash
|
||||
cd ui
|
||||
npm run dev # Dev server at localhost:5173
|
||||
npm run build # Production build
|
||||
npm run check # svelte-check (type-check)
|
||||
npm run paraglide # Regenerate i18n messages (run after editing messages/*.json)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Three services communicate via PocketBase records and a Redis/Valkey task queue:
|
||||
|
||||
**Backend** (`backend/cmd/backend`) — HTTP REST API. Handles reads, enqueues tasks to Redis via Asynq, returns presigned MinIO URLs. Minimal processing; delegates heavy work to the runner.
|
||||
|
||||
**Runner** (`backend/cmd/runner`) — Asynq task worker. Processes scraping, TTS audio generation, AI text/image generation. Reads/writes PocketBase and MinIO directly.
|
||||
|
||||
**UI** (`ui/`) — SvelteKit 2 + Svelte 5 SSR app. Consumes the backend API. Uses Paraglide JS for i18n (5 locales).
|
||||
|
||||
### Data layer
|
||||
| Service | Role |
|
||||
|---------|------|
|
||||
| **PocketBase** (SQLite) | Auth, structured records (books, chapters, tasks, subscriptions) |
|
||||
| **MinIO** (S3-compatible) | Object storage — chapter text, audio files, images |
|
||||
| **Meilisearch** | Full-text search (runner indexes, backend reads) |
|
||||
| **Redis/Valkey** | Asynq task queue + presigned URL cache |
|
||||
|
||||
### Key backend packages
|
||||
- `internal/backend/` — HTTP handlers and server setup
|
||||
- `internal/runner/` — Task processor implementations
|
||||
- `internal/storage/` — Unified MinIO + PocketBase interface (all data access goes through here)
|
||||
- `internal/orchestrator/` — Task orchestration across services
|
||||
- `internal/taskqueue/` — Enqueue helpers (backend side)
|
||||
- `internal/asynqqueue/` — Asynq queue setup (runner side)
|
||||
- `internal/config/` — Environment variable loading (Doppler-injected at runtime, no .env files)
|
||||
- `internal/presigncache/` — Redis cache for MinIO presigned URLs
|
||||
|
||||
### UI routing conventions (SvelteKit)
|
||||
- `+page.svelte` / `+page.server.ts` — Page + server-side load
|
||||
- `+layout.svelte` / `+layout.server.ts` — Layouts
|
||||
- `routes/api/` — API routes (`+server.ts`)
|
||||
- `lib/audio.svelte.ts` — Client-side audio playback store (Svelte 5 runes)
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- **Svelte 5 runes only** — use `$state`, `$derived`, `$effect`; do not use Svelte 4 stores or reactive statements.
|
||||
- **Modern Go idioms** — structured logging via `log/slog`, OpenTelemetry tracing throughout.
|
||||
- **No direct MinIO/PocketBase client calls** outside the `internal/storage/` package.
|
||||
- **Secrets via Doppler** — never use `.env` files. All secrets are injected by Doppler CLI.
|
||||
- **CI/CD is Gitea Actions** (`.gitea/workflows/`), not GitHub Actions. Use `gitea.ref_name`/`gitea.sha` variables.
|
||||
- **Git hooks** in `.githooks/` — enable with `just setup`.
|
||||
- **i18n**: translation files live in `ui/messages/{en,es,fr,de,pt}.json`; run `npm run paraglide` after editing them.
|
||||
- **Error tracking**: GlitchTip with per-service DSNs (backend id/2, runner id/3, UI id/1) stored in Doppler.
|
||||
@@ -27,7 +27,10 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
-o /out/runner ./cmd/runner && \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w" \
|
||||
-o /out/healthcheck ./cmd/healthcheck
|
||||
-o /out/healthcheck ./cmd/healthcheck && \
|
||||
CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w" \
|
||||
-o /out/pocketbase ./cmd/pocketbase
|
||||
|
||||
# ── backend service ──────────────────────────────────────────────────────────
|
||||
# Uses Alpine (not distroless) so ffmpeg is available for on-demand voice
|
||||
@@ -40,6 +43,18 @@ COPY --from=builder /out/backend /backend
|
||||
USER appuser
|
||||
ENTRYPOINT ["/backend"]
|
||||
|
||||
# ── pocketbase service ───────────────────────────────────────────────────────
|
||||
# Runs the custom PocketBase binary with Go migrations baked in.
|
||||
# On every `serve` startup it applies any pending migrations automatically.
|
||||
# Data is stored in /pb_data (mounted as a Docker volume in production).
|
||||
FROM alpine:3.21 AS pocketbase
|
||||
RUN apk add --no-cache ca-certificates wget
|
||||
COPY --from=builder /out/pocketbase /pocketbase
|
||||
RUN mkdir -p /pb_data
|
||||
VOLUME /pb_data
|
||||
EXPOSE 8090
|
||||
CMD ["/pocketbase", "serve", "--dir", "/pb_data", "--http", "0.0.0.0:8090"]
|
||||
|
||||
# ── runner service ───────────────────────────────────────────────────────────
|
||||
# Uses Alpine (not distroless) so ffmpeg is available for WAV→MP3 transcoding
|
||||
# when pocket-tts voices are used.
|
||||
|
||||
@@ -177,6 +177,7 @@ func run() error {
|
||||
DefaultVoice: cfg.Kokoro.DefaultVoice,
|
||||
Version: version,
|
||||
Commit: commit,
|
||||
AdminToken: cfg.HTTP.AdminToken,
|
||||
},
|
||||
backend.Dependencies{
|
||||
BookReader: store,
|
||||
@@ -199,6 +200,7 @@ func run() error {
|
||||
BookWriter: store,
|
||||
AIJobStore: store,
|
||||
BookAdminStore: store,
|
||||
NotificationStore: store,
|
||||
Log: log,
|
||||
},
|
||||
)
|
||||
|
||||
47
backend/cmd/pocketbase/main.go
Normal file
47
backend/cmd/pocketbase/main.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Command pocketbase is a thin wrapper that runs PocketBase as a Go framework
|
||||
// with version-controlled Go migrations.
|
||||
//
|
||||
// On every `serve`, PocketBase automatically applies any pending migrations from
|
||||
// the migrations/ package before accepting traffic.
|
||||
//
|
||||
// Usage (Docker):
|
||||
//
|
||||
// ./pocketbase serve --dir /pb_data --http 0.0.0.0:8090
|
||||
//
|
||||
// Migration workflow:
|
||||
//
|
||||
// # Generate a timestamped stub:
|
||||
// go run ./cmd/pocketbase migrate create "description"
|
||||
// # Apply manually (also runs automatically on serve):
|
||||
// go run ./cmd/pocketbase migrate up
|
||||
// # Revert last migration:
|
||||
// go run ./cmd/pocketbase migrate down 1
|
||||
// # After migrating an existing install, mark existing schema as done:
|
||||
// go run ./cmd/pocketbase migrate history-sync
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||
|
||||
// Register all migrations via init().
|
||||
_ "github.com/libnovel/backend/migrations"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := pocketbase.New()
|
||||
|
||||
// Register the migrate sub-command.
|
||||
// Automigrate: false — migrations are written by hand, never auto-generated
|
||||
// from Admin UI changes. Pending migrations still apply automatically on
|
||||
// every `serve` regardless of this flag.
|
||||
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
|
||||
Automigrate: false,
|
||||
})
|
||||
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,14 @@ module github.com/libnovel/backend
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/getsentry/sentry-go v0.43.0
|
||||
github.com/hibiken/asynq v0.26.0
|
||||
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d
|
||||
github.com/meilisearch/meilisearch-go v0.36.1
|
||||
github.com/minio/minio-go/v7 v7.0.98
|
||||
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
|
||||
@@ -20,43 +22,57 @@ require (
|
||||
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.51.0
|
||||
golang.org/x/net v0.52.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 // indirect
|
||||
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/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/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/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/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/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/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
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
|
||||
@@ -66,14 +82,20 @@ require (
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/image v0.32.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/image v0.38.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/grpc v1.79.2 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.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
|
||||
)
|
||||
|
||||
108
backend/go.sum
108
backend/go.sum
@@ -2,6 +2,9 @@ github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1
|
||||
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=
|
||||
@@ -14,16 +17,28 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
||||
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=
|
||||
@@ -35,18 +50,27 @@ 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=
|
||||
@@ -57,6 +81,8 @@ 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=
|
||||
@@ -70,6 +96,10 @@ 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=
|
||||
@@ -82,6 +112,8 @@ 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=
|
||||
@@ -92,6 +124,10 @@ 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.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.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
@@ -102,14 +138,24 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM
|
||||
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.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=
|
||||
@@ -165,15 +211,19 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
||||
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
||||
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=
|
||||
@@ -182,8 +232,10 @@ 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.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
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=
|
||||
@@ -191,6 +243,8 @@ 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=
|
||||
@@ -202,8 +256,8 @@ 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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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=
|
||||
@@ -214,6 +268,7 @@ 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=
|
||||
@@ -222,8 +277,8 @@ 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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -232,9 +287,13 @@ 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=
|
||||
@@ -246,7 +305,36 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
||||
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=
|
||||
|
||||
@@ -1279,6 +1279,10 @@ func (s *Server) handleTranslationRead(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Translated chapter content is immutable once generated — cache aggressively.
|
||||
// The browser and any intermediary (CDN, SvelteKit fetch cache) can reuse this
|
||||
// response for 1 hour without hitting MinIO again.
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400")
|
||||
writeJSON(w, 0, map[string]string{"html": buf.String(), "lang": lang})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ package backend
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
// handleDismissNotification handles DELETE /api/notifications/{id}.
|
||||
@@ -14,12 +12,11 @@ func (s *Server) handleDismissNotification(w http.ResponseWriter, r *http.Reques
|
||||
jsonError(w, http.StatusBadRequest, "notification id required")
|
||||
return
|
||||
}
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
if s.deps.NotificationStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
|
||||
return
|
||||
}
|
||||
if err := store.DeleteNotification(r.Context(), id); err != nil {
|
||||
if err := s.deps.NotificationStore.DeleteNotification(r.Context(), id); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "dismiss notification: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -33,12 +30,11 @@ func (s *Server) handleClearAllNotifications(w http.ResponseWriter, r *http.Requ
|
||||
jsonError(w, http.StatusBadRequest, "user_id required")
|
||||
return
|
||||
}
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
if s.deps.NotificationStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
|
||||
return
|
||||
}
|
||||
if err := store.ClearAllNotifications(r.Context(), userID); err != nil {
|
||||
if err := s.deps.NotificationStore.ClearAllNotifications(r.Context(), userID); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "clear notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -52,12 +48,11 @@ func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.R
|
||||
jsonError(w, http.StatusBadRequest, "user_id required")
|
||||
return
|
||||
}
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
if s.deps.NotificationStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
|
||||
return
|
||||
}
|
||||
if err := store.MarkAllNotificationsRead(r.Context(), userID); err != nil {
|
||||
if err := s.deps.NotificationStore.MarkAllNotificationsRead(r.Context(), userID); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "mark all read: "+err.Error())
|
||||
return
|
||||
}
|
||||
@@ -80,13 +75,12 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
if s.deps.NotificationStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := store.ListNotifications(r.Context(), userID, 50)
|
||||
items, err := s.deps.NotificationStore.ListNotifications(r.Context(), userID, 50)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "list notifications: "+err.Error())
|
||||
return
|
||||
@@ -97,7 +91,7 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
|
||||
for _, item := range items {
|
||||
b, _ := json.Marshal(item)
|
||||
var n notification
|
||||
json.Unmarshal(b, &n)
|
||||
json.Unmarshal(b, &n) //nolint:errcheck
|
||||
notifications = append(notifications, n)
|
||||
}
|
||||
|
||||
@@ -111,16 +105,15 @@ func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
if s.deps.NotificationStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "notification store not configured")
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.MarkNotificationRead(r.Context(), id); err != nil {
|
||||
if err := s.deps.NotificationStore.MarkNotificationRead(r.Context(), id); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "mark read: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]any{"success": true})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,6 +313,8 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
|
||||
// Mark job as done in PB, persisting results so the Review button works.
|
||||
// Use context.Background() — r.Context() may be cancelled if the SSE client
|
||||
// disconnected before processing finished, which would silently drop results.
|
||||
if jobID != "" && s.deps.AIJobStore != nil {
|
||||
status := domain.TaskStatusDone
|
||||
if jobCtx.Err() != nil {
|
||||
@@ -321,7 +323,7 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
|
||||
resultsJSON, _ := json.Marshal(allResults)
|
||||
finalPayload := fmt.Sprintf(`{"pattern":%q,"slug":%q,"results":%s}`,
|
||||
req.Pattern, req.Slug, string(resultsJSON))
|
||||
_ = s.deps.AIJobStore.UpdateAIJob(r.Context(), jobID, map[string]any{
|
||||
_ = s.deps.AIJobStore.UpdateAIJob(context.Background(), jobID, map[string]any{
|
||||
"status": string(status),
|
||||
"items_done": chaptersDone,
|
||||
"finished": time.Now().Format(time.RFC3339),
|
||||
|
||||
@@ -94,6 +94,10 @@ type Dependencies struct {
|
||||
// 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
|
||||
}
|
||||
@@ -107,6 +111,9 @@ type Config struct {
|
||||
// Version and Commit are embedded in /health and /api/version responses.
|
||||
Version string
|
||||
Commit string
|
||||
// AdminToken is the bearer token required for all /api/admin/* endpoints.
|
||||
// When empty a startup warning is logged and admin routes are unprotected.
|
||||
AdminToken string
|
||||
}
|
||||
|
||||
// Server is the HTTP API server.
|
||||
@@ -133,9 +140,30 @@ func New(cfg Config, deps Dependencies) *Server {
|
||||
return &Server{cfg: cfg, deps: deps}
|
||||
}
|
||||
|
||||
// requireAdmin returns a handler that enforces Bearer token authentication.
|
||||
// When AdminToken is empty all requests are allowed through (with a warning logged
|
||||
// once at startup via ListenAndServe).
|
||||
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.cfg.AdminToken == "" {
|
||||
next(w, r)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer "+s.cfg.AdminToken {
|
||||
jsonError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// ListenAndServe registers all routes and starts the HTTP server.
|
||||
// It blocks until ctx is cancelled, then performs a graceful shutdown.
|
||||
func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
if s.cfg.AdminToken == "" {
|
||||
s.deps.Log.Warn("backend: BACKEND_ADMIN_TOKEN is not set — /api/admin/* endpoints are unprotected")
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Health / version
|
||||
@@ -200,68 +228,73 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /api/translation/status/{slug}/{n}", s.handleTranslationStatus)
|
||||
mux.HandleFunc("GET /api/translation/{slug}/{n}", s.handleTranslationRead)
|
||||
|
||||
// admin is a shorthand that wraps every /api/admin/* handler with bearer-token auth.
|
||||
admin := func(pattern string, h http.HandlerFunc) {
|
||||
mux.HandleFunc(pattern, s.requireAdmin(h))
|
||||
}
|
||||
|
||||
// Admin translation endpoints
|
||||
mux.HandleFunc("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
|
||||
mux.HandleFunc("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
|
||||
admin("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
|
||||
admin("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
|
||||
|
||||
// Admin audio endpoints
|
||||
mux.HandleFunc("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
|
||||
mux.HandleFunc("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
|
||||
mux.HandleFunc("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
|
||||
admin("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
|
||||
admin("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
|
||||
admin("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
|
||||
|
||||
// Admin image generation endpoints
|
||||
mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
|
||||
mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen)
|
||||
mux.HandleFunc("POST /api/admin/image-gen/async", s.handleAdminImageGenAsync)
|
||||
mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
|
||||
mux.HandleFunc("POST /api/admin/image-gen/save-chapter-image", s.handleAdminImageGenSaveChapterImage)
|
||||
admin("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
|
||||
admin("POST /api/admin/image-gen", s.handleAdminImageGen)
|
||||
admin("POST /api/admin/image-gen/async", s.handleAdminImageGenAsync)
|
||||
admin("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
|
||||
admin("POST /api/admin/image-gen/save-chapter-image", s.handleAdminImageGenSaveChapterImage)
|
||||
|
||||
// Chapter image serving
|
||||
mux.HandleFunc("GET /api/chapter-image/{domain}/{slug}/{n}", s.handleGetChapterImage)
|
||||
mux.HandleFunc("HEAD /api/chapter-image/{domain}/{slug}/{n}", s.handleHeadChapterImage)
|
||||
|
||||
// Admin text generation endpoints (chapter names + book description)
|
||||
mux.HandleFunc("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/async", s.handleAdminTextGenChapterNamesAsync)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description/async", s.handleAdminTextGenDescriptionAsync)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
|
||||
admin("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)
|
||||
admin("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
|
||||
admin("POST /api/admin/text-gen/chapter-names/async", s.handleAdminTextGenChapterNamesAsync)
|
||||
admin("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
|
||||
admin("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
|
||||
admin("POST /api/admin/text-gen/description/async", s.handleAdminTextGenDescriptionAsync)
|
||||
admin("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
|
||||
|
||||
// Admin catalogue enrichment endpoints
|
||||
mux.HandleFunc("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings)
|
||||
mux.HandleFunc("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore)
|
||||
mux.HandleFunc("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers)
|
||||
mux.HandleFunc("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
|
||||
mux.HandleFunc("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
|
||||
admin("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline)
|
||||
admin("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres)
|
||||
admin("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres)
|
||||
admin("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings)
|
||||
admin("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore)
|
||||
admin("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers)
|
||||
admin("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
|
||||
admin("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
|
||||
|
||||
// Admin AI job tracking endpoints
|
||||
mux.HandleFunc("GET /api/admin/ai-jobs", s.handleAdminListAIJobs)
|
||||
mux.HandleFunc("GET /api/admin/ai-jobs/{id}", s.handleAdminGetAIJob)
|
||||
mux.HandleFunc("POST /api/admin/ai-jobs/{id}/cancel", s.handleAdminCancelAIJob)
|
||||
admin("GET /api/admin/ai-jobs", s.handleAdminListAIJobs)
|
||||
admin("GET /api/admin/ai-jobs/{id}", s.handleAdminGetAIJob)
|
||||
admin("POST /api/admin/ai-jobs/{id}/cancel", s.handleAdminCancelAIJob)
|
||||
|
||||
// Auto-prompt generation from book/chapter content
|
||||
mux.HandleFunc("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)
|
||||
admin("POST /api/admin/image-gen/auto-prompt", s.handleAdminImageGenAutoPrompt)
|
||||
|
||||
// Admin data repair endpoints
|
||||
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
|
||||
admin("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
|
||||
|
||||
// Admin book management (soft-delete / hard-delete)
|
||||
mux.HandleFunc("PATCH /api/admin/books/{slug}/archive", s.handleAdminArchiveBook)
|
||||
mux.HandleFunc("PATCH /api/admin/books/{slug}/unarchive", s.handleAdminUnarchiveBook)
|
||||
mux.HandleFunc("DELETE /api/admin/books/{slug}", s.handleAdminDeleteBook)
|
||||
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)
|
||||
mux.HandleFunc("POST /api/admin/books/{slug}/split-chapters", s.handleAdminSplitChapters)
|
||||
admin("POST /api/admin/books/{slug}/split-chapters", s.handleAdminSplitChapters)
|
||||
|
||||
// Import (PDF/EPUB)
|
||||
mux.HandleFunc("POST /api/admin/import", s.handleAdminImport)
|
||||
mux.HandleFunc("GET /api/admin/import", s.handleAdminImportList)
|
||||
mux.HandleFunc("GET /api/admin/import/{id}", s.handleAdminImportStatus)
|
||||
admin("POST /api/admin/import", s.handleAdminImport)
|
||||
admin("GET /api/admin/import", s.handleAdminImportList)
|
||||
admin("GET /api/admin/import/{id}", s.handleAdminImportStatus)
|
||||
|
||||
// Notifications
|
||||
mux.HandleFunc("GET /api/notifications", s.handleListNotifications)
|
||||
|
||||
@@ -247,3 +247,14 @@ type ImportFileStore interface {
|
||||
// GetImportChapters retrieves the pre-parsed chapters JSON.
|
||||
GetImportChapters(ctx context.Context, key string) ([]byte, error)
|
||||
}
|
||||
|
||||
// NotificationStore manages per-user in-app notifications.
|
||||
// Always wired directly to the concrete *storage.Store so it works
|
||||
// regardless of whether the Asynq task-queue wrapper is in use.
|
||||
type NotificationStore interface {
|
||||
ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error)
|
||||
MarkNotificationRead(ctx context.Context, id string) error
|
||||
MarkAllNotificationsRead(ctx context.Context, userID string) error
|
||||
DeleteNotification(ctx context.Context, id string) error
|
||||
ClearAllNotifications(ctx context.Context, userID string) error
|
||||
}
|
||||
|
||||
@@ -92,6 +92,10 @@ type LibreTranslate struct {
|
||||
type HTTP struct {
|
||||
// Addr is the listen address, e.g. ":8080"
|
||||
Addr string
|
||||
// AdminToken is the bearer token required for all /api/admin/* endpoints.
|
||||
// Set via BACKEND_ADMIN_TOKEN. When empty, admin endpoints are unprotected —
|
||||
// only acceptable when the backend is unreachable from the public internet.
|
||||
AdminToken string
|
||||
}
|
||||
|
||||
// Meilisearch holds connection settings for the Meilisearch full-text search service.
|
||||
@@ -242,7 +246,8 @@ func Load() Config {
|
||||
},
|
||||
|
||||
HTTP: HTTP{
|
||||
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
|
||||
Addr: envOr("BACKEND_HTTP_ADDR", ":8080"),
|
||||
AdminToken: envOr("BACKEND_ADMIN_TOKEN", ""),
|
||||
},
|
||||
|
||||
Runner: Runner{
|
||||
|
||||
@@ -773,7 +773,7 @@ func (s *Store) CreateNotification(ctx context.Context, userID, title, message,
|
||||
|
||||
// ListNotifications returns notifications for a user.
|
||||
func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error) {
|
||||
filter := fmt.Sprintf("user_id='%s'", userID)
|
||||
filter := fmt.Sprintf(`user_id=%q`, userID)
|
||||
items, err := s.pb.listAll(ctx, "notifications", filter, "-created")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -805,7 +805,7 @@ func (s *Store) DeleteNotification(ctx context.Context, id string) error {
|
||||
|
||||
// ClearAllNotifications deletes all notifications for a user.
|
||||
func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error {
|
||||
filter := fmt.Sprintf("user_id='%s'", userID)
|
||||
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)
|
||||
@@ -823,7 +823,7 @@ func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error
|
||||
|
||||
// MarkAllNotificationsRead marks all notifications for a user as read.
|
||||
func (s *Store) MarkAllNotificationsRead(ctx context.Context, userID string) error {
|
||||
filter := fmt.Sprintf("user_id='%s'&&read=false", userID)
|
||||
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)
|
||||
|
||||
431
backend/migrations/20260414000001_initial_schema.go
Normal file
431
backend/migrations/20260414000001_initial_schema.go
Normal file
@@ -0,0 +1,431 @@
|
||||
// Migration 1 — full schema baseline.
|
||||
//
|
||||
// Creates all 21 collections that were previously bootstrapped by
|
||||
// scripts/pb-init-v3.sh. Also creates the initial superuser from the
|
||||
// POCKETBASE_ADMIN_EMAIL / POCKETBASE_ADMIN_PASSWORD env vars (first run only).
|
||||
//
|
||||
// This migration is intentionally idempotent: each collection is skipped if it
|
||||
// already exists. This makes it safe to apply on an existing install without
|
||||
// running `migrate history-sync` first — existing collections are left untouched
|
||||
// and migration 2 still runs to add the three fields that were missing.
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
steps := []func(core.App) error{
|
||||
createBooks,
|
||||
createChaptersIdx,
|
||||
createRanking,
|
||||
createProgress,
|
||||
createScrapingTasks,
|
||||
createAudioJobs,
|
||||
createAppUsers,
|
||||
createUserSessions,
|
||||
createUserLibrary,
|
||||
createUserSettings,
|
||||
createUserSubscriptions,
|
||||
createBookComments,
|
||||
createCommentVotes,
|
||||
createTranslationJobs,
|
||||
createImportTasks,
|
||||
createNotifications,
|
||||
createPushSubscriptions,
|
||||
createAIJobs,
|
||||
createDiscoveryVotes,
|
||||
createBookRatings,
|
||||
createSiteConfig,
|
||||
createInitialSuperuser,
|
||||
}
|
||||
for _, step := range steps {
|
||||
if err := step(app); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, func(app core.App) error {
|
||||
// Down: drop all collections in safe reverse order.
|
||||
names := []string{
|
||||
"site_config", "book_ratings", "discovery_votes", "ai_jobs",
|
||||
"push_subscriptions", "notifications", "import_tasks",
|
||||
"translation_jobs", "comment_votes", "book_comments",
|
||||
"user_subscriptions", "user_settings", "user_library",
|
||||
"user_sessions", "app_users", "audio_jobs", "scraping_tasks",
|
||||
"progress", "ranking", "chapters_idx", "books",
|
||||
}
|
||||
for _, name := range names {
|
||||
coll, err := app.FindCollectionByNameOrId(name)
|
||||
if err != nil {
|
||||
continue // already absent — safe to skip
|
||||
}
|
||||
if err := app.Delete(coll); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// saveIfAbsent saves the collection only when no collection with that name
|
||||
// exists yet. This makes the migration safe to run on an existing install
|
||||
// without history-sync — already-created collections are simply skipped.
|
||||
func saveIfAbsent(app core.App, c *core.Collection) error {
|
||||
if _, err := app.FindCollectionByNameOrId(c.Name); err == nil {
|
||||
return nil // already exists — skip
|
||||
}
|
||||
return app.Save(c)
|
||||
}
|
||||
|
||||
// ── Collection creators ───────────────────────────────────────────────────────
|
||||
|
||||
func createBooks(app core.App) error {
|
||||
c := core.NewBaseCollection("books")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.TextField{Name: "title", Required: true},
|
||||
&core.TextField{Name: "author"},
|
||||
&core.TextField{Name: "cover"},
|
||||
&core.TextField{Name: "status"},
|
||||
&core.JSONField{Name: "genres"},
|
||||
&core.TextField{Name: "summary"},
|
||||
&core.NumberField{Name: "total_chapters"},
|
||||
&core.TextField{Name: "source_url"},
|
||||
&core.NumberField{Name: "ranking"},
|
||||
&core.TextField{Name: "meta_updated"},
|
||||
&core.BoolField{Name: "archived"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createChaptersIdx(app core.App) error {
|
||||
c := core.NewBaseCollection("chapters_idx")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.NumberField{Name: "number", Required: true},
|
||||
&core.TextField{Name: "title"},
|
||||
)
|
||||
// Enforce uniqueness on (slug, number) — prevents duplicate chapter entries.
|
||||
c.AddIndex("idx_chapters_idx_slug_number", true, "slug, number", "")
|
||||
// Allow fast "recently updated books" queries.
|
||||
c.AddIndex("idx_chapters_idx_created", false, "created", "")
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createRanking(app core.App) error {
|
||||
c := core.NewBaseCollection("ranking")
|
||||
c.Fields.Add(
|
||||
&core.NumberField{Name: "rank", Required: true},
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.TextField{Name: "title"},
|
||||
&core.TextField{Name: "author"},
|
||||
&core.TextField{Name: "cover"},
|
||||
&core.TextField{Name: "status"},
|
||||
&core.JSONField{Name: "genres"},
|
||||
&core.TextField{Name: "source_url"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createProgress(app core.App) error {
|
||||
c := core.NewBaseCollection("progress")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "session_id", Required: true},
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.NumberField{Name: "chapter"},
|
||||
&core.TextField{Name: "user_id"},
|
||||
&core.NumberField{Name: "audio_time"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createScrapingTasks(app core.App) error {
|
||||
c := core.NewBaseCollection("scraping_tasks")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "kind"},
|
||||
&core.TextField{Name: "target_url"},
|
||||
&core.NumberField{Name: "from_chapter"},
|
||||
&core.NumberField{Name: "to_chapter"},
|
||||
&core.TextField{Name: "worker_id"},
|
||||
&core.TextField{Name: "status", Required: true},
|
||||
&core.NumberField{Name: "books_found"},
|
||||
&core.NumberField{Name: "chapters_scraped"},
|
||||
&core.NumberField{Name: "chapters_skipped"},
|
||||
&core.NumberField{Name: "errors"},
|
||||
&core.TextField{Name: "error_message"},
|
||||
&core.DateField{Name: "started"},
|
||||
&core.DateField{Name: "finished"},
|
||||
&core.DateField{Name: "heartbeat_at"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createAudioJobs(app core.App) error {
|
||||
c := core.NewBaseCollection("audio_jobs")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "cache_key", Required: true},
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.NumberField{Name: "chapter", Required: true},
|
||||
&core.TextField{Name: "voice"},
|
||||
&core.TextField{Name: "worker_id"},
|
||||
&core.TextField{Name: "status", Required: true},
|
||||
&core.TextField{Name: "error_message"},
|
||||
&core.DateField{Name: "started"},
|
||||
&core.DateField{Name: "finished"},
|
||||
&core.DateField{Name: "heartbeat_at"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createAppUsers(app core.App) error {
|
||||
c := core.NewBaseCollection("app_users")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "username", Required: true},
|
||||
&core.TextField{Name: "password_hash"},
|
||||
&core.TextField{Name: "role"},
|
||||
&core.TextField{Name: "avatar_url"},
|
||||
&core.TextField{Name: "email"},
|
||||
&core.BoolField{Name: "email_verified"},
|
||||
&core.TextField{Name: "verification_token"},
|
||||
&core.TextField{Name: "verification_token_exp"},
|
||||
&core.TextField{Name: "oauth_provider"},
|
||||
&core.TextField{Name: "oauth_id"},
|
||||
&core.TextField{Name: "polar_customer_id"},
|
||||
&core.TextField{Name: "polar_subscription_id"},
|
||||
&core.BoolField{Name: "notify_new_chapters"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createUserSessions(app core.App) error {
|
||||
c := core.NewBaseCollection("user_sessions")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "user_id", Required: true},
|
||||
&core.TextField{Name: "session_id", Required: true},
|
||||
&core.TextField{Name: "user_agent"},
|
||||
&core.TextField{Name: "ip"},
|
||||
&core.TextField{Name: "device_fingerprint"},
|
||||
// created_at is a custom text field (not the system `created` date field).
|
||||
&core.TextField{Name: "created_at"},
|
||||
&core.TextField{Name: "last_seen"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createUserLibrary(app core.App) error {
|
||||
c := core.NewBaseCollection("user_library")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "session_id", Required: true},
|
||||
&core.TextField{Name: "user_id"},
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.TextField{Name: "saved_at"},
|
||||
&core.TextField{Name: "shelf"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createUserSettings(app core.App) error {
|
||||
c := core.NewBaseCollection("user_settings")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "session_id", Required: true},
|
||||
&core.TextField{Name: "user_id"},
|
||||
&core.BoolField{Name: "auto_next"},
|
||||
&core.TextField{Name: "voice"},
|
||||
&core.NumberField{Name: "speed"},
|
||||
&core.TextField{Name: "theme"},
|
||||
&core.TextField{Name: "locale"},
|
||||
&core.TextField{Name: "font_family"},
|
||||
&core.NumberField{Name: "font_size"},
|
||||
&core.BoolField{Name: "announce_chapter"},
|
||||
&core.TextField{Name: "audio_mode"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createUserSubscriptions(app core.App) error {
|
||||
c := core.NewBaseCollection("user_subscriptions")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "follower_id", Required: true},
|
||||
&core.TextField{Name: "followee_id", Required: true},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createBookComments(app core.App) error {
|
||||
c := core.NewBaseCollection("book_comments")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.TextField{Name: "user_id"},
|
||||
&core.TextField{Name: "username"},
|
||||
&core.TextField{Name: "body"},
|
||||
&core.NumberField{Name: "upvotes"},
|
||||
&core.NumberField{Name: "downvotes"},
|
||||
&core.TextField{Name: "parent_id"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createCommentVotes(app core.App) error {
|
||||
c := core.NewBaseCollection("comment_votes")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "comment_id", Required: true},
|
||||
&core.TextField{Name: "user_id"},
|
||||
&core.TextField{Name: "session_id"},
|
||||
&core.TextField{Name: "vote"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createTranslationJobs(app core.App) error {
|
||||
c := core.NewBaseCollection("translation_jobs")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "cache_key", Required: true},
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.NumberField{Name: "chapter", Required: true},
|
||||
&core.TextField{Name: "lang", Required: true},
|
||||
&core.TextField{Name: "worker_id"},
|
||||
&core.TextField{Name: "status", Required: true},
|
||||
&core.TextField{Name: "error_message"},
|
||||
&core.DateField{Name: "started"},
|
||||
&core.DateField{Name: "finished"},
|
||||
&core.DateField{Name: "heartbeat_at"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createImportTasks(app core.App) error {
|
||||
c := core.NewBaseCollection("import_tasks")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.TextField{Name: "title", Required: true},
|
||||
&core.TextField{Name: "file_name"},
|
||||
&core.TextField{Name: "file_type"},
|
||||
&core.TextField{Name: "object_key"},
|
||||
&core.TextField{Name: "chapters_key"},
|
||||
&core.TextField{Name: "author"},
|
||||
&core.TextField{Name: "cover_url"},
|
||||
&core.TextField{Name: "genres"},
|
||||
&core.TextField{Name: "summary"},
|
||||
&core.TextField{Name: "book_status"},
|
||||
&core.TextField{Name: "worker_id"},
|
||||
&core.TextField{Name: "initiator_user_id"},
|
||||
&core.TextField{Name: "status", Required: true},
|
||||
&core.NumberField{Name: "chapters_done"},
|
||||
&core.NumberField{Name: "chapters_total"},
|
||||
&core.TextField{Name: "error_message"},
|
||||
&core.DateField{Name: "started"},
|
||||
&core.DateField{Name: "finished"},
|
||||
&core.DateField{Name: "heartbeat_at"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createNotifications(app core.App) error {
|
||||
c := core.NewBaseCollection("notifications")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "user_id", Required: true},
|
||||
&core.TextField{Name: "title", Required: true},
|
||||
&core.TextField{Name: "message"},
|
||||
&core.TextField{Name: "link"},
|
||||
&core.BoolField{Name: "read"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createPushSubscriptions(app core.App) error {
|
||||
c := core.NewBaseCollection("push_subscriptions")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "user_id", Required: true},
|
||||
&core.TextField{Name: "endpoint", Required: true},
|
||||
&core.TextField{Name: "p256dh", Required: true},
|
||||
&core.TextField{Name: "auth", Required: true},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createAIJobs(app core.App) error {
|
||||
c := core.NewBaseCollection("ai_jobs")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "kind", Required: true},
|
||||
&core.TextField{Name: "slug"},
|
||||
&core.TextField{Name: "status", Required: true},
|
||||
&core.NumberField{Name: "from_item"},
|
||||
&core.NumberField{Name: "to_item"},
|
||||
&core.NumberField{Name: "items_done"},
|
||||
&core.NumberField{Name: "items_total"},
|
||||
&core.TextField{Name: "model"},
|
||||
&core.TextField{Name: "payload"},
|
||||
&core.TextField{Name: "error_message"},
|
||||
&core.DateField{Name: "started"},
|
||||
&core.DateField{Name: "finished"},
|
||||
&core.DateField{Name: "heartbeat_at"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createDiscoveryVotes(app core.App) error {
|
||||
c := core.NewBaseCollection("discovery_votes")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "session_id", Required: true},
|
||||
&core.TextField{Name: "user_id"},
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.TextField{Name: "action", Required: true},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createBookRatings(app core.App) error {
|
||||
c := core.NewBaseCollection("book_ratings")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "session_id", Required: true},
|
||||
&core.TextField{Name: "user_id"},
|
||||
&core.TextField{Name: "slug", Required: true},
|
||||
&core.NumberField{Name: "rating", Required: true},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
func createSiteConfig(app core.App) error {
|
||||
c := core.NewBaseCollection("site_config")
|
||||
c.Fields.Add(
|
||||
&core.TextField{Name: "decoration"},
|
||||
&core.TextField{Name: "logoAnimation"},
|
||||
&core.TextField{Name: "eventLabel"},
|
||||
)
|
||||
return saveIfAbsent(app, c)
|
||||
}
|
||||
|
||||
// createInitialSuperuser creates the first PocketBase superuser from env vars.
|
||||
// It is a no-op if a superuser with that email already exists, or if the env
|
||||
// vars are not set. This replaces the superuser bootstrap block in
|
||||
// scripts/pb-init-v3.sh.
|
||||
func createInitialSuperuser(app core.App) error {
|
||||
email := os.Getenv("POCKETBASE_ADMIN_EMAIL")
|
||||
password := os.Getenv("POCKETBASE_ADMIN_PASSWORD")
|
||||
if email == "" || password == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
existing, _ := app.FindFirstRecordByData("_superusers", "email", email)
|
||||
if existing != nil {
|
||||
return nil // superuser already exists
|
||||
}
|
||||
|
||||
superusers, err := app.FindCollectionByNameOrId("_superusers")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := core.NewRecord(superusers)
|
||||
record.Set("email", email)
|
||||
record.Set("password", password)
|
||||
record.Set("passwordConfirm", password)
|
||||
return app.Save(record)
|
||||
}
|
||||
71
backend/migrations/20260414000002_missing_fields.go
Normal file
71
backend/migrations/20260414000002_missing_fields.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// Migration 2 — add fields present in code but absent from pb-init-v3.sh.
|
||||
//
|
||||
// Discovered by auditing every PocketBase field access in the Go backend
|
||||
// and SvelteKit UI against the collection definitions in pb-init-v3.sh:
|
||||
//
|
||||
// books.rating (number) — written by WriteMetadata but never defined.
|
||||
// app_users.notify_new_chapters_push (bool) — used in UI push-notification opt-in.
|
||||
// book_comments.chapter (number) — used to scope comments to a chapter (0 = book-level).
|
||||
//
|
||||
// The check for field existence makes this migration safe to re-apply on
|
||||
// a fresh install where migration 1 already created the collections without
|
||||
// these fields.
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
type addition struct {
|
||||
collection string
|
||||
field core.Field
|
||||
}
|
||||
additions := []addition{
|
||||
{"books", &core.NumberField{Name: "rating"}},
|
||||
{"app_users", &core.BoolField{Name: "notify_new_chapters_push"}},
|
||||
{"book_comments", &core.NumberField{Name: "chapter"}},
|
||||
}
|
||||
for _, a := range additions {
|
||||
coll, err := app.FindCollectionByNameOrId(a.collection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if coll.Fields.GetByName(a.field.GetName()) != nil {
|
||||
continue // already present — idempotent
|
||||
}
|
||||
coll.Fields.Add(a.field)
|
||||
if err := app.Save(coll); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}, func(app core.App) error {
|
||||
type removal struct {
|
||||
collection string
|
||||
field string
|
||||
}
|
||||
removals := []removal{
|
||||
{"books", "rating"},
|
||||
{"app_users", "notify_new_chapters_push"},
|
||||
{"book_comments", "chapter"},
|
||||
}
|
||||
for _, r := range removals {
|
||||
coll, err := app.FindCollectionByNameOrId(r.collection)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
f := coll.Fields.GetByName(r.field)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
coll.Fields.RemoveById(f.GetId())
|
||||
if err := app.Save(coll); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
106
docker-bake.hcl
Normal file
106
docker-bake.hcl
Normal file
@@ -0,0 +1,106 @@
|
||||
# docker-bake.hcl — defines all five production images.
|
||||
#
|
||||
# CI passes version info as environment variables; locally everything gets :dev tags.
|
||||
#
|
||||
# Local build (no push):
|
||||
# docker buildx bake
|
||||
#
|
||||
# CI environment variables: VERSION, MAJOR_MINOR, COMMIT, BUILD_TIME
|
||||
|
||||
variable "DOCKER_USER" { default = "kalekber" }
|
||||
variable "VERSION" { default = "dev" } # e.g. "4.1.6" (no leading v)
|
||||
variable "MAJOR_MINOR" { default = "dev" } # e.g. "4.1"
|
||||
variable "COMMIT" { default = "unknown" }
|
||||
variable "BUILD_TIME" { default = "" }
|
||||
|
||||
# ── Shared defaults ───────────────────────────────────────────────────────────
|
||||
|
||||
target "_defaults" {
|
||||
pull = true
|
||||
# CI overrides to push=true via --set *.output=type=image,push=true
|
||||
output = ["type=image,push=false"]
|
||||
cache-to = ["type=inline"]
|
||||
}
|
||||
|
||||
# ── Go targets (share the backend/ build context + builder stage) ─────────────
|
||||
|
||||
target "backend" {
|
||||
inherits = ["_defaults"]
|
||||
context = "backend"
|
||||
target = "backend"
|
||||
tags = [
|
||||
"${DOCKER_USER}/libnovel-backend:${VERSION}",
|
||||
"${DOCKER_USER}/libnovel-backend:${MAJOR_MINOR}",
|
||||
"${DOCKER_USER}/libnovel-backend:latest",
|
||||
]
|
||||
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-backend:latest"]
|
||||
args = {
|
||||
VERSION = VERSION
|
||||
COMMIT = COMMIT
|
||||
}
|
||||
}
|
||||
|
||||
target "runner" {
|
||||
inherits = ["_defaults"]
|
||||
context = "backend"
|
||||
target = "runner"
|
||||
tags = [
|
||||
"${DOCKER_USER}/libnovel-runner:${VERSION}",
|
||||
"${DOCKER_USER}/libnovel-runner:${MAJOR_MINOR}",
|
||||
"${DOCKER_USER}/libnovel-runner:latest",
|
||||
]
|
||||
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-runner:latest"]
|
||||
args = {
|
||||
VERSION = VERSION
|
||||
COMMIT = COMMIT
|
||||
}
|
||||
}
|
||||
|
||||
target "pocketbase" {
|
||||
inherits = ["_defaults"]
|
||||
context = "backend"
|
||||
target = "pocketbase"
|
||||
tags = [
|
||||
"${DOCKER_USER}/libnovel-pocketbase:${VERSION}",
|
||||
"${DOCKER_USER}/libnovel-pocketbase:${MAJOR_MINOR}",
|
||||
"${DOCKER_USER}/libnovel-pocketbase:latest",
|
||||
]
|
||||
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-pocketbase:latest"]
|
||||
}
|
||||
|
||||
# ── UI (SvelteKit — separate context) ────────────────────────────────────────
|
||||
|
||||
target "ui" {
|
||||
inherits = ["_defaults"]
|
||||
context = "ui"
|
||||
tags = [
|
||||
"${DOCKER_USER}/libnovel-ui:${VERSION}",
|
||||
"${DOCKER_USER}/libnovel-ui:${MAJOR_MINOR}",
|
||||
"${DOCKER_USER}/libnovel-ui:latest",
|
||||
]
|
||||
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-ui:latest"]
|
||||
args = {
|
||||
BUILD_VERSION = VERSION
|
||||
BUILD_COMMIT = COMMIT
|
||||
BUILD_TIME = BUILD_TIME
|
||||
}
|
||||
}
|
||||
|
||||
# ── Caddy (custom plugins — separate context) ─────────────────────────────────
|
||||
|
||||
target "caddy" {
|
||||
inherits = ["_defaults"]
|
||||
context = "caddy"
|
||||
tags = [
|
||||
"${DOCKER_USER}/libnovel-caddy:${VERSION}",
|
||||
"${DOCKER_USER}/libnovel-caddy:${MAJOR_MINOR}",
|
||||
"${DOCKER_USER}/libnovel-caddy:latest",
|
||||
]
|
||||
cache-from = ["type=registry,ref=${DOCKER_USER}/libnovel-caddy:latest"]
|
||||
}
|
||||
|
||||
# ── Default group: all five images ────────────────────────────────────────────
|
||||
|
||||
group "default" {
|
||||
targets = ["backend", "runner", "pocketbase", "ui", "caddy"]
|
||||
}
|
||||
@@ -67,12 +67,21 @@ services:
|
||||
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD}"
|
||||
|
||||
# ─── PocketBase (auth + structured data) ─────────────────────────────────────
|
||||
# Custom binary built from backend/cmd/pocketbase — runs Go migrations on every
|
||||
# startup before accepting traffic, replacing the old pb-init-v3.sh script.
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:latest
|
||||
image: kalekber/libnovel-pocketbase:${GIT_TAG:-latest}
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
target: pocketbase
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PB_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||
PB_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||
# Used by migration 1 to create the initial superuser on a fresh install.
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||
# No public port — accessed only by backend/runner on the internal network.
|
||||
expose:
|
||||
- "8090"
|
||||
@@ -82,25 +91,12 @@ services:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/health"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ─── PocketBase collection bootstrap ─────────────────────────────────────────
|
||||
pb-init:
|
||||
image: alpine:3.19
|
||||
depends_on:
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POCKETBASE_URL: "http://pocketbase:8090"
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||
volumes:
|
||||
- ./scripts/pb-init-v3.sh:/pb-init.sh:ro
|
||||
entrypoint: ["sh", "/pb-init.sh"]
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
# ─── Meilisearch (full-text search) ──────────────────────────────────────────
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:latest
|
||||
image: getmeili/meilisearch:v1.40.0
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MEILI_MASTER_KEY: "${MEILI_MASTER_KEY}"
|
||||
@@ -166,8 +162,6 @@ services:
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 35s
|
||||
depends_on:
|
||||
pb-init:
|
||||
condition: service_completed_successfully
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
@@ -184,6 +178,7 @@ services:
|
||||
environment:
|
||||
<<: *infra-env
|
||||
BACKEND_HTTP_ADDR: ":8080"
|
||||
BACKEND_ADMIN_TOKEN: "${BACKEND_ADMIN_TOKEN}"
|
||||
LOG_LEVEL: "${LOG_LEVEL}"
|
||||
KOKORO_URL: "${KOKORO_URL}"
|
||||
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||
@@ -220,8 +215,6 @@ services:
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 135s
|
||||
depends_on:
|
||||
pb-init:
|
||||
condition: service_completed_successfully
|
||||
pocketbase:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
@@ -275,8 +268,6 @@ services:
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 35s
|
||||
depends_on:
|
||||
pb-init:
|
||||
condition: service_completed_successfully
|
||||
backend:
|
||||
condition: service_healthy
|
||||
pocketbase:
|
||||
@@ -295,6 +286,7 @@ services:
|
||||
POCKETBASE_ADMIN_EMAIL: "${POCKETBASE_ADMIN_EMAIL}"
|
||||
POCKETBASE_ADMIN_PASSWORD: "${POCKETBASE_ADMIN_PASSWORD}"
|
||||
AUTH_SECRET: "${AUTH_SECRET}"
|
||||
BACKEND_ADMIN_TOKEN: "${BACKEND_ADMIN_TOKEN}"
|
||||
DEBUG_LOGIN_TOKEN: "${DEBUG_LOGIN_TOKEN}"
|
||||
PUBLIC_MINIO_PUBLIC_URL: "${MINIO_PUBLIC_ENDPOINT}"
|
||||
# Valkey
|
||||
|
||||
4
justfile
4
justfile
@@ -56,14 +56,14 @@ build-svc svc:
|
||||
|
||||
# Push all custom images to Docker Hub (requires docker login)
|
||||
push:
|
||||
{{doppler}} docker compose push backend runner ui caddy
|
||||
{{doppler}} docker compose push backend runner ui caddy pocketbase
|
||||
|
||||
# Build then push all custom images
|
||||
build-push: build push
|
||||
|
||||
# Pull all images from Docker Hub (uses GIT_TAG from Doppler)
|
||||
pull-images:
|
||||
{{doppler}} docker compose pull backend runner ui caddy
|
||||
{{doppler}} docker compose pull backend runner ui caddy pocketbase
|
||||
|
||||
# Pull all third-party base images (minio, pocketbase, etc.)
|
||||
pull-infra:
|
||||
|
||||
@@ -376,6 +376,13 @@ create "book_ratings" '{
|
||||
{"name":"rating", "type":"number", "required":true}
|
||||
]}'
|
||||
|
||||
create "site_config" '{
|
||||
"name":"site_config","type":"base","fields":[
|
||||
{"name":"decoration", "type":"text"},
|
||||
{"name":"logoAnimation", "type":"text"},
|
||||
{"name":"eventLabel", "type":"text"}
|
||||
]}'
|
||||
|
||||
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
|
||||
add_field "scraping_tasks" "heartbeat_at" "date"
|
||||
add_field "audio_jobs" "heartbeat_at" "date"
|
||||
|
||||
@@ -21,11 +21,7 @@ ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
|
||||
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
|
||||
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
|
||||
|
||||
# PREBUILT=1 skips npm run build — used in CI when the build/ directory has
|
||||
# already been compiled (and debug IDs injected) by a prior job. The caller
|
||||
# must copy the pre-built build/ into the Docker context before building.
|
||||
ARG PREBUILT=0
|
||||
RUN [ "$PREBUILT" = "1" ] || npm run build
|
||||
RUN npm run build
|
||||
|
||||
# ── Runtime image ──────────────────────────────────────────────────────────────
|
||||
# adapter-node bundles most server-side code, but packages with dynamic
|
||||
|
||||
@@ -250,6 +250,52 @@ html {
|
||||
animation: progress-bar 4s cubic-bezier(0.1, 0.05, 0.1, 1) forwards;
|
||||
}
|
||||
|
||||
/* ── Logo animation classes (used in nav + admin preview) ───────────── */
|
||||
@keyframes logo-glow-pulse {
|
||||
0%, 100% { text-shadow: 0 0 6px color-mix(in srgb, var(--color-brand) 60%, transparent); }
|
||||
50% { text-shadow: 0 0 18px color-mix(in srgb, var(--color-brand) 90%, transparent), 0 0 32px color-mix(in srgb, var(--color-brand) 40%, transparent); }
|
||||
}
|
||||
.logo-anim-glow {
|
||||
animation: logo-glow-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes logo-shimmer {
|
||||
0% { background-position: -200% center; }
|
||||
100% { background-position: 200% center; }
|
||||
}
|
||||
.logo-anim-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-brand) 0%,
|
||||
color-mix(in srgb, var(--color-brand) 40%, white) 40%,
|
||||
var(--color-brand) 50%,
|
||||
color-mix(in srgb, var(--color-brand) 40%, white) 60%,
|
||||
var(--color-brand) 100%
|
||||
);
|
||||
background-size: 200% auto;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: logo-shimmer 2.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes logo-pulse-scale {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.06); }
|
||||
}
|
||||
.logo-anim-pulse {
|
||||
display: inline-block;
|
||||
animation: logo-pulse-scale 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes logo-rainbow {
|
||||
0% { filter: hue-rotate(0deg); }
|
||||
100% { filter: hue-rotate(360deg); }
|
||||
}
|
||||
.logo-anim-rainbow {
|
||||
animation: logo-rainbow 4s linear infinite;
|
||||
}
|
||||
|
||||
/* ── Respect reduced motion — disable all decorative animations ─────── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
|
||||
285
ui/src/lib/components/SeasonalDecoration.svelte
Normal file
285
ui/src/lib/components/SeasonalDecoration.svelte
Normal file
@@ -0,0 +1,285 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* SeasonalDecoration — full-viewport canvas particle overlay.
|
||||
*
|
||||
* Modes:
|
||||
* snow — white circular snowflakes drifting down with gentle sway
|
||||
* sakura — pink/white ellipse petals falling and rotating
|
||||
* fireflies — small glowing dots floating up, pulsing opacity
|
||||
* leaves — orange/red/yellow tear-drop shapes tumbling down
|
||||
* stars — white stars twinkling in place (fixed positions, opacity animation)
|
||||
*/
|
||||
|
||||
type Mode = 'snow' | 'sakura' | 'fireflies' | 'leaves' | 'stars';
|
||||
|
||||
interface Props { mode: Mode }
|
||||
let { mode }: Props = $props();
|
||||
|
||||
let canvas = $state<HTMLCanvasElement | null>(null);
|
||||
let raf = 0;
|
||||
|
||||
// ── Particle types ──────────────────────────────────────────────────────
|
||||
|
||||
interface Particle {
|
||||
x: number; y: number; r: number;
|
||||
vx: number; vy: number;
|
||||
angle: number; vAngle: number;
|
||||
opacity: number; vOpacity: number;
|
||||
color: string;
|
||||
// star-specific
|
||||
twinkleOffset?: number;
|
||||
}
|
||||
|
||||
// ── Palette helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function rand(min: number, max: number) { return min + Math.random() * (max - min); }
|
||||
function randInt(min: number, max: number) { return Math.floor(rand(min, max + 1)); }
|
||||
|
||||
const SNOW_COLORS = ['rgba(255,255,255,0.85)', 'rgba(200,220,255,0.75)', 'rgba(220,235,255,0.8)'];
|
||||
const SAKURA_COLORS = ['rgba(255,182,193,0.85)', 'rgba(255,200,210,0.8)', 'rgba(255,240,245,0.9)', 'rgba(255,160,180,0.75)'];
|
||||
const FIREFLY_COLORS = ['rgba(180,255,100,0.9)', 'rgba(220,255,150,0.85)', 'rgba(255,255,180,0.8)'];
|
||||
const LEAF_COLORS = ['rgba(210,80,20,0.85)', 'rgba(190,120,30,0.8)', 'rgba(220,160,40,0.85)', 'rgba(180,60,10,0.8)', 'rgba(240,140,30,0.9)'];
|
||||
const STAR_COLORS = ['rgba(255,255,255,0.9)', 'rgba(255,240,180,0.85)', 'rgba(180,210,255,0.8)'];
|
||||
|
||||
// ── Spawn helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function spawnSnow(W: number, H: number): Particle {
|
||||
return {
|
||||
x: rand(0, W), y: rand(-H * 0.2, -4),
|
||||
r: rand(1.5, 5),
|
||||
vx: rand(-0.4, 0.4), vy: rand(0.6, 2.0),
|
||||
angle: 0, vAngle: 0,
|
||||
opacity: rand(0.5, 1), vOpacity: 0,
|
||||
color: SNOW_COLORS[randInt(0, SNOW_COLORS.length - 1)],
|
||||
};
|
||||
}
|
||||
|
||||
function spawnSakura(W: number, H: number): Particle {
|
||||
return {
|
||||
x: rand(0, W), y: rand(-H * 0.2, -4),
|
||||
r: rand(3, 7),
|
||||
vx: rand(-0.6, 0.6), vy: rand(0.5, 1.6),
|
||||
angle: rand(0, Math.PI * 2), vAngle: rand(-0.03, 0.03),
|
||||
opacity: rand(0.6, 1), vOpacity: 0,
|
||||
color: SAKURA_COLORS[randInt(0, SAKURA_COLORS.length - 1)],
|
||||
};
|
||||
}
|
||||
|
||||
function spawnFirefly(W: number, H: number): Particle {
|
||||
return {
|
||||
x: rand(0, W), y: rand(H * 0.3, H),
|
||||
r: rand(1.5, 3.5),
|
||||
vx: rand(-0.3, 0.3), vy: rand(-0.8, -0.2),
|
||||
angle: 0, vAngle: 0,
|
||||
opacity: rand(0.2, 0.8), vOpacity: rand(0.008, 0.025) * (Math.random() < 0.5 ? 1 : -1),
|
||||
color: FIREFLY_COLORS[randInt(0, FIREFLY_COLORS.length - 1)],
|
||||
};
|
||||
}
|
||||
|
||||
function spawnLeaf(W: number, H: number): Particle {
|
||||
return {
|
||||
x: rand(0, W), y: rand(-H * 0.2, -4),
|
||||
r: rand(4, 9),
|
||||
vx: rand(-1.2, 1.2), vy: rand(0.8, 2.5),
|
||||
angle: rand(0, Math.PI * 2), vAngle: rand(-0.05, 0.05),
|
||||
opacity: rand(0.6, 1), vOpacity: 0,
|
||||
color: LEAF_COLORS[randInt(0, LEAF_COLORS.length - 1)],
|
||||
};
|
||||
}
|
||||
|
||||
function spawnStar(W: number, H: number): Particle {
|
||||
return {
|
||||
x: rand(0, W), y: rand(0, H),
|
||||
r: rand(0.8, 2.5),
|
||||
vx: 0, vy: 0,
|
||||
angle: 0, vAngle: 0,
|
||||
opacity: rand(0.1, 0.9),
|
||||
vOpacity: rand(0.004, 0.015) * (Math.random() < 0.5 ? 1 : -1),
|
||||
color: STAR_COLORS[randInt(0, STAR_COLORS.length - 1)],
|
||||
twinkleOffset: rand(0, Math.PI * 2),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Draw helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function drawSnow(ctx: CanvasRenderingContext2D, p: Particle) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawSakura(ctx: CanvasRenderingContext2D, p: Particle) {
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.angle);
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, p.r * 1.8, p.r, 0, 0, Math.PI * 2);
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawFirefly(ctx: CanvasRenderingContext2D, p: Particle) {
|
||||
// Glow effect: large soft circle + small bright core
|
||||
const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * 4);
|
||||
grd.addColorStop(0, p.color);
|
||||
grd.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r * 4, 0, Math.PI * 2);
|
||||
ctx.fillStyle = grd;
|
||||
ctx.globalAlpha = p.opacity * 0.6;
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawLeaf(ctx: CanvasRenderingContext2D, p: Particle) {
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate(p.angle);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -p.r * 1.5);
|
||||
ctx.bezierCurveTo(p.r * 1.2, -p.r * 0.5, p.r * 1.2, p.r * 0.5, 0, p.r * 1.5);
|
||||
ctx.bezierCurveTo(-p.r * 1.2, p.r * 0.5, -p.r * 1.2, -p.r * 0.5, 0, -p.r * 1.5);
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.globalAlpha = p.opacity;
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawStar(ctx: CanvasRenderingContext2D, p: Particle, t: number) {
|
||||
const pulse = 0.5 + 0.5 * Math.sin(t * 0.002 + (p.twinkleOffset ?? 0));
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.globalAlpha = p.opacity * pulse;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ── Particle count by mode ────────────────────────────────────────────────
|
||||
|
||||
const COUNT: Record<Mode, number> = {
|
||||
snow: 120, sakura: 60, fireflies: 50, leaves: 45, stars: 150,
|
||||
};
|
||||
|
||||
// ── Main effect ──────────────────────────────────────────────────────────
|
||||
|
||||
$effect(() => {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let W = window.innerWidth;
|
||||
let H = window.innerHeight;
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
|
||||
const onResize = () => {
|
||||
W = window.innerWidth;
|
||||
H = window.innerHeight;
|
||||
canvas!.width = W;
|
||||
canvas!.height = H;
|
||||
// Reseed stars on resize since they're positionally fixed
|
||||
if (mode === 'stars') {
|
||||
particles.length = 0;
|
||||
for (let i = 0; i < COUNT.stars; i++) particles.push(spawnStar(W, H));
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
const n = COUNT[mode];
|
||||
const particles: Particle[] = [];
|
||||
|
||||
// Pre-scatter initial particles across the full height
|
||||
for (let i = 0; i < n; i++) {
|
||||
let p: Particle;
|
||||
switch (mode) {
|
||||
case 'snow': p = spawnSnow(W, H); p.y = rand(0, H); break;
|
||||
case 'sakura': p = spawnSakura(W, H); p.y = rand(0, H); break;
|
||||
case 'fireflies': p = spawnFirefly(W, H); break;
|
||||
case 'leaves': p = spawnLeaf(W, H); p.y = rand(0, H); break;
|
||||
case 'stars': p = spawnStar(W, H); break;
|
||||
}
|
||||
particles.push(p);
|
||||
}
|
||||
|
||||
let t = 0;
|
||||
|
||||
function tick() {
|
||||
ctx!.clearRect(0, 0, W, H);
|
||||
ctx!.save();
|
||||
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const p = particles[i];
|
||||
|
||||
switch (mode) {
|
||||
case 'snow': {
|
||||
// Gentle horizontal sway
|
||||
p.vx = Math.sin(t * 0.001 + p.y * 0.01) * 0.5;
|
||||
p.x += p.vx; p.y += p.vy;
|
||||
if (p.y > H + 10) particles[i] = spawnSnow(W, H);
|
||||
else drawSnow(ctx!, p);
|
||||
break;
|
||||
}
|
||||
case 'sakura': {
|
||||
p.vx = Math.sin(t * 0.0008 + p.y * 0.008) * 0.8;
|
||||
p.x += p.vx; p.y += p.vy;
|
||||
p.angle += p.vAngle;
|
||||
if (p.y > H + 20) particles[i] = spawnSakura(W, H);
|
||||
else drawSakura(ctx!, p);
|
||||
break;
|
||||
}
|
||||
case 'fireflies': {
|
||||
p.x += p.vx + Math.sin(t * 0.002 + i) * 0.3;
|
||||
p.y += p.vy;
|
||||
p.opacity += p.vOpacity;
|
||||
if (p.opacity >= 1) { p.opacity = 1; p.vOpacity *= -1; }
|
||||
if (p.opacity <= 0.1) { p.opacity = 0.1; p.vOpacity *= -1; }
|
||||
if (p.y < -10) particles[i] = spawnFirefly(W, H);
|
||||
else drawFirefly(ctx!, p);
|
||||
break;
|
||||
}
|
||||
case 'leaves': {
|
||||
p.vx = Math.sin(t * 0.001 + p.y * 0.01) * 1.2 + p.vx * 0.02;
|
||||
p.x += p.vx; p.y += p.vy;
|
||||
p.angle += p.vAngle;
|
||||
if (p.y > H + 20) particles[i] = spawnLeaf(W, H);
|
||||
else drawLeaf(ctx!, p);
|
||||
break;
|
||||
}
|
||||
case 'stars': {
|
||||
drawStar(ctx!, p, t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx!.restore();
|
||||
t++;
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', onResize);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Fixed full-viewport overlay, pointer-events-none so all clicks pass through.
|
||||
z-index 40 keeps it below the sticky nav (z-50) but above page content.
|
||||
-->
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
class="fixed inset-0 z-40 pointer-events-none"
|
||||
aria-hidden="true"
|
||||
></canvas>
|
||||
@@ -2498,6 +2498,90 @@ export async function getUserStats(
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Site Config ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A single singleton record in the `site_config` collection holds global
|
||||
// display settings (seasonal decoration, logo animation, etc.).
|
||||
// The record is lazily created on first write; reads return safe defaults if
|
||||
// the collection/record doesn't exist yet.
|
||||
|
||||
export interface SiteConfig {
|
||||
/** Seasonal decoration particle effect: null = off */
|
||||
decoration: 'snow' | 'sakura' | 'fireflies' | 'leaves' | 'stars' | null;
|
||||
/** Special CSS class applied to the nav logo text */
|
||||
logoAnimation: 'none' | 'glow' | 'rainbow' | 'pulse' | 'shimmer';
|
||||
/** Human-readable label for the current event/season shown in a small badge */
|
||||
eventLabel: string;
|
||||
}
|
||||
|
||||
const SITE_CONFIG_DEFAULTS: SiteConfig = {
|
||||
decoration: null,
|
||||
logoAnimation: 'none',
|
||||
eventLabel: '',
|
||||
};
|
||||
|
||||
// In-memory short cache so every SSR request doesn't hammer PocketBase
|
||||
let _siteConfigCache: { value: SiteConfig; exp: number } | null = null;
|
||||
const SITE_CONFIG_CACHE_TTL = 60_000; // 60 seconds
|
||||
|
||||
export async function getSiteConfig(): Promise<SiteConfig> {
|
||||
if (_siteConfigCache && Date.now() < _siteConfigCache.exp) {
|
||||
return _siteConfigCache.value;
|
||||
}
|
||||
try {
|
||||
const list = await pbGet<{ items: Array<{ id: string } & SiteConfig> }>(
|
||||
'/api/collections/site_config/records?perPage=1'
|
||||
);
|
||||
const row = list.items?.[0];
|
||||
const value: SiteConfig = row
|
||||
? {
|
||||
decoration: row.decoration ?? null,
|
||||
logoAnimation: row.logoAnimation ?? 'none',
|
||||
eventLabel: row.eventLabel ?? '',
|
||||
}
|
||||
: { ...SITE_CONFIG_DEFAULTS };
|
||||
_siteConfigCache = { value, exp: Date.now() + SITE_CONFIG_CACHE_TTL };
|
||||
return value;
|
||||
} catch {
|
||||
// Collection may not exist yet — return defaults silently
|
||||
return { ...SITE_CONFIG_DEFAULTS };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSiteConfig(patch: Partial<SiteConfig>): Promise<void> {
|
||||
// Bust cache
|
||||
_siteConfigCache = null;
|
||||
|
||||
// Ensure collection exists and find the singleton record
|
||||
let existingId: string | null = null;
|
||||
try {
|
||||
const list = await pbGet<{ items: Array<{ id: string }> }>(
|
||||
'/api/collections/site_config/records?perPage=1'
|
||||
);
|
||||
existingId = list.items?.[0]?.id ?? null;
|
||||
} catch {
|
||||
// Collection doesn't exist yet — create it via PocketBase API
|
||||
await pbPost('/api/collections', {
|
||||
name: 'site_config',
|
||||
type: 'base',
|
||||
fields: [
|
||||
{ name: 'decoration', type: 'text' },
|
||||
{ name: 'logoAnimation', type: 'text' },
|
||||
{ name: 'eventLabel', type: 'text' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (existingId) {
|
||||
await pbPatch(`/api/collections/site_config/records/${existingId}`, patch);
|
||||
} else {
|
||||
await pbPost('/api/collections/site_config/records', {
|
||||
...SITE_CONFIG_DEFAULTS,
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── AI Jobs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const AI_JOBS_CACHE_KEY = 'admin:ai_jobs';
|
||||
|
||||
@@ -15,18 +15,31 @@ import { env } from '$env/dynamic/private';
|
||||
import * as cache from '$lib/server/cache';
|
||||
|
||||
export const BACKEND_URL = env.BACKEND_API_URL ?? 'http://localhost:8080';
|
||||
const ADMIN_TOKEN = env.BACKEND_ADMIN_TOKEN ?? '';
|
||||
|
||||
/**
|
||||
* Fetch a path on the backend, throwing a 502 on network failures.
|
||||
*
|
||||
* The `path` must start with `/` (e.g. `/api/voices`).
|
||||
* Requests to `/api/admin/*` automatically include the Bearer token from
|
||||
* the BACKEND_ADMIN_TOKEN environment variable.
|
||||
*
|
||||
* SvelteKit `error()` exceptions are always re-thrown so callers can
|
||||
* short-circuit correctly inside their own catch blocks.
|
||||
*/
|
||||
export async function backendFetch(path: string, init?: RequestInit): Promise<Response> {
|
||||
let finalInit = init;
|
||||
if (ADMIN_TOKEN && path.startsWith('/api/admin')) {
|
||||
finalInit = {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${ADMIN_TOKEN}`,
|
||||
...((init?.headers ?? {}) as Record<string, string>)
|
||||
}
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await fetch(`${BACKEND_URL}${path}`, init);
|
||||
return await fetch(`${BACKEND_URL}${path}`, finalInit);
|
||||
} catch (e) {
|
||||
// Re-throw SvelteKit HTTP errors so they propagate to the framework.
|
||||
if (e instanceof Error && 'status' in e) throw e;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { getSettings } from '$lib/server/pocketbase';
|
||||
import { getSettings, getSiteConfig } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
// Routes that are accessible without being logged in
|
||||
@@ -60,6 +60,11 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
||||
return {
|
||||
user: locals.user,
|
||||
isPro: locals.isPro,
|
||||
settings
|
||||
settings,
|
||||
siteConfig: await getSiteConfig().catch(() => ({
|
||||
decoration: null as null,
|
||||
logoAnimation: 'none' as const,
|
||||
eventLabel: '',
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
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();
|
||||
@@ -94,6 +95,20 @@
|
||||
let listeningModeOpen = $state(false);
|
||||
let listeningModeChapters = $state(false);
|
||||
|
||||
// ── Site config (seasonal decoration + logo animation) ──────────────────
|
||||
// svelte-ignore state_referenced_locally
|
||||
let siteDecoration = $state(data.siteConfig?.decoration ?? null);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let siteLogoAnim = $state(data.siteConfig?.logoAnimation ?? 'none');
|
||||
// svelte-ignore state_referenced_locally
|
||||
let siteEventLabel = $state(data.siteConfig?.eventLabel ?? '');
|
||||
// Refresh when invalidateAll() re-runs layout load (e.g. after admin saves)
|
||||
$effect(() => {
|
||||
siteDecoration = data.siteConfig?.decoration ?? null;
|
||||
siteLogoAnim = data.siteConfig?.logoAnimation ?? 'none';
|
||||
siteEventLabel = data.siteConfig?.eventLabel ?? '';
|
||||
});
|
||||
|
||||
// Build time formatted in the user's local timezone (populated on mount so
|
||||
// SSR and CSR don't produce a mismatch — SSR renders nothing, hydration fills it in).
|
||||
let buildTimeLocal = $state('');
|
||||
@@ -522,8 +537,18 @@
|
||||
{/if}
|
||||
<header class="border-b border-(--color-border) bg-(--color-surface) sticky top-0 z-50">
|
||||
<nav class="max-w-6xl mx-auto px-4 h-14 flex items-center gap-6">
|
||||
<a href="/" class="text-(--color-brand) font-bold text-lg tracking-tight hover:text-(--color-brand-dim) shrink-0">
|
||||
<a href="/" class="text-(--color-brand) font-bold text-lg tracking-tight hover:text-(--color-brand-dim) shrink-0 flex items-center gap-1.5
|
||||
{siteLogoAnim === 'glow' ? 'logo-anim-glow' : ''}
|
||||
{siteLogoAnim === 'shimmer' ? 'logo-anim-shimmer' : ''}
|
||||
{siteLogoAnim === 'pulse' ? 'logo-anim-pulse' : ''}
|
||||
{siteLogoAnim === 'rainbow' ? 'logo-anim-rainbow' : ''}
|
||||
">
|
||||
libnovel
|
||||
{#if siteEventLabel}
|
||||
<span class="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 leading-none tracking-wide">
|
||||
{siteEventLabel}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
{#if page.data.book?.title && /\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
|
||||
@@ -1173,3 +1198,13 @@
|
||||
searchOpen = true;
|
||||
}
|
||||
}} />
|
||||
|
||||
<!-- Seasonal decoration overlay — rendered above page content, below nav -->
|
||||
{#if siteDecoration}
|
||||
<SeasonalDecoration mode={siteDecoration} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Logo animation keyframes are defined globally in app.css */
|
||||
/* This block intentionally left minimal — all logo-anim-* classes live in app.css */
|
||||
</style>
|
||||
|
||||
@@ -247,13 +247,16 @@
|
||||
<!-- ── Streak widget ───────────────────────────────────────────────────────────── -->
|
||||
{#if streak > 0}
|
||||
<div class="mb-6 flex items-center gap-3 flex-wrap text-sm">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
||||
<span class="font-semibold text-(--color-text)">{streak}</span>
|
||||
<span class="text-(--color-muted)">day{streak !== 1 ? 's' : ''} reading</span>
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand)/10 border border-(--color-brand)/30 text-(--color-brand) font-semibold">
|
||||
<svg class="w-4 h-4 shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M13.5 0.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>
|
||||
</svg>
|
||||
{streak} day{streak !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{#if data.stats.booksInProgress > 0}
|
||||
<span class="text-(--color-muted)">
|
||||
<span class="font-semibold text-(--color-text)">{data.stats.booksInProgress}</span> {data.stats.booksInProgress === 1 ? 'book' : 'books'} in progress
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-muted)">
|
||||
<span class="font-semibold text-(--color-text)">{data.stats.booksInProgress}</span>
|
||||
{data.stats.booksInProgress === 1 ? 'book' : 'books'} in progress
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -263,32 +266,39 @@
|
||||
{#if shelfBooks.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
|
||||
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each shelfBooks as { book, chapter }}
|
||||
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-32 sm:w-36">
|
||||
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
|
||||
<a href="/books/{book.slug}/chapters/{chapter}" class="block">
|
||||
<div class="aspect-[2/3] overflow-hidden relative">
|
||||
{#if book.cover}
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Chapter badge -->
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
|
||||
{m.home_chapter_badge({ n: String(chapter) })}
|
||||
</span>
|
||||
<!-- Reading progress bar -->
|
||||
{#if book.total_chapters > 0}
|
||||
{@const pct = Math.min(100, Math.round((chapter / book.total_chapters) * 100))}
|
||||
<div class="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
|
||||
<div class="h-full bg-(--color-brand) transition-all" style="width: {pct}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
<!-- Listen button (hover overlay) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(book.slug, chapter)}
|
||||
class="absolute bottom-8 left-1.5 w-7 h-7 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
class="absolute bottom-9 left-1.5 w-7 h-7 rounded-full bg-black/60 text-white flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
|
||||
title="Listen"
|
||||
aria-label="Listen to chapter {chapter}"
|
||||
>
|
||||
@@ -296,6 +306,9 @@
|
||||
</button>
|
||||
<a href="/books/{book.slug}/chapters/{chapter}" class="p-2 block">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-(--color-muted) truncate mt-0.5">{book.author}</p>
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -307,7 +320,7 @@
|
||||
{#if data.continueCompleted.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">Completed</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">Completed</h2>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each data.continueCompleted as { book, chapter }}
|
||||
@@ -318,7 +331,7 @@
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="absolute top-1.5 right-1.5 text-xs bg-green-600/90 text-white font-bold px-1.5 py-0.5 rounded">Done</span>
|
||||
@@ -339,7 +352,7 @@
|
||||
{#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-base font-bold text-(--color-text)">Ready to Listen</h2>
|
||||
<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"
|
||||
@@ -360,7 +373,7 @@
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Headphones badge -->
|
||||
@@ -402,7 +415,7 @@
|
||||
{#if !hidden.has('browse-genre')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">Browse by genre</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">Browse by genre</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
<button type="button" onclick={() => hide('browse-genre')} title="Hide section"
|
||||
@@ -415,8 +428,11 @@
|
||||
</div>
|
||||
<div class="flex gap-2 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4">
|
||||
{#each GENRES as genre}
|
||||
{@const isTop = data.topGenre && genre.toLowerCase() === data.topGenre.toLowerCase()}
|
||||
<a href="/catalogue?genre={encodeURIComponent(genre)}"
|
||||
class="shrink-0 px-3.5 py-1.5 rounded-full border border-(--color-border) bg-(--color-surface-2) text-sm text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors whitespace-nowrap">
|
||||
class="shrink-0 px-3.5 py-1.5 rounded-full border text-sm transition-colors whitespace-nowrap {isTop
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand) font-semibold'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text) hover:bg-(--color-surface-3)'}">
|
||||
{genre}
|
||||
</a>
|
||||
{/each}
|
||||
@@ -428,7 +444,7 @@
|
||||
{#if data.trendingBooks.length > 0 && !hidden.has('trending')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">Trending Now</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">Trending Now</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
<button type="button" onclick={() => hide('trending')} title="Hide section"
|
||||
@@ -449,7 +465,7 @@
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="absolute top-1.5 left-1.5 text-xs bg-(--color-brand)/80 text-(--color-surface) font-bold px-1.5 py-0.5 rounded">#{book.ranking}</span>
|
||||
@@ -477,15 +493,18 @@
|
||||
{#if data.recommendedBooks.length > 0 && data.topGenre && !hidden.has('because-you-read')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">
|
||||
Because you read <span class="text-(--color-brand)">{data.topGenre}</span>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">
|
||||
Because you read <span class="text-(--color-brand)">{data.topGenre ? data.topGenre.charAt(0).toUpperCase() + data.topGenre.slice(1) : ''}</span>
|
||||
</h2>
|
||||
<button type="button" onclick={() => hide('because-you-read')} title="Hide section"
|
||||
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/catalogue?genre={encodeURIComponent(data.topGenre ?? '')}" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
|
||||
<button type="button" onclick={() => hide('because-you-read')} title="Hide section"
|
||||
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each data.recommendedBooks as book}
|
||||
@@ -497,7 +516,7 @@
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -524,7 +543,7 @@
|
||||
{#if dedupedRecent.length > 0 && !hidden.has('recently-updated')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
<button type="button" onclick={() => hide('recently-updated')} title="Hide section"
|
||||
@@ -545,7 +564,7 @@
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if count > 1}
|
||||
@@ -577,7 +596,7 @@
|
||||
{#if data.subscriptionFeed.length > 0 && !hidden.has('from-following')}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-base font-bold text-(--color-text)">{m.home_from_following()}</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_from_following()}</h2>
|
||||
<button type="button" onclick={() => hide('from-following')} title="Hide section"
|
||||
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -594,7 +613,7 @@
|
||||
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
<span class="text-4xl font-bold text-(--color-muted) select-none opacity-50">{(book.title ?? '?').charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { listAIJobs } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
if (locals.user?.role !== 'admin') {
|
||||
redirect(302, '/');
|
||||
}
|
||||
const jobs = await listAIJobs().catch((e) => {
|
||||
log.warn('admin/layout', 'failed to load ai jobs for sidebar badge', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
const runningAiJobs = jobs.filter((j) => j.status === 'running' || j.status === 'pending').length;
|
||||
return { runningAiJobs };
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
const internalLinks = [
|
||||
{
|
||||
@@ -52,6 +53,11 @@
|
||||
href: '/admin/changelog',
|
||||
label: () => m.admin_nav_changelog(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/site-theme',
|
||||
label: () => 'Site Theme',
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />`
|
||||
}
|
||||
];
|
||||
|
||||
@@ -100,8 +106,9 @@
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
data: LayoutData;
|
||||
}
|
||||
let { children }: Props = $props();
|
||||
let { children, data }: Props = $props();
|
||||
|
||||
let sidebarOpen = $state(false);
|
||||
</script>
|
||||
@@ -131,6 +138,7 @@
|
||||
<nav class="flex flex-col gap-0.5">
|
||||
{#each internalLinks as link}
|
||||
{@const active = page.url.pathname.startsWith(link.href)}
|
||||
{@const isAiJobs = link.href === '/admin/ai-jobs'}
|
||||
<a
|
||||
href={link.href}
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
@@ -142,7 +150,12 @@
|
||||
<svg class="w-3.5 h-3.5 shrink-0 {active ? 'text-(--color-brand)' : 'opacity-50'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{@html link.icon}
|
||||
</svg>
|
||||
{link.label()}
|
||||
<span class="flex-1">{link.label()}</span>
|
||||
{#if isAiJobs && data.runningAiJobs > 0}
|
||||
<span class="text-[10px] font-bold tabular-nums px-1.5 py-0.5 rounded-full bg-(--color-brand) text-black leading-none">
|
||||
{data.runningAiJobs}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
@@ -6,8 +6,7 @@ export type { AIJob };
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
// Stream jobs so navigation is instant; list populates a moment later.
|
||||
const jobs = listAIJobs().catch((e): AIJob[] => {
|
||||
const jobs = await listAIJobs().catch((e): AIJob[] => {
|
||||
log.warn('admin/ai-jobs', 'failed to load ai jobs', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
let jobs = $state<AIJob[]>([]);
|
||||
|
||||
// Resolve streamed promise on load and on server reloads (invalidateAll)
|
||||
// data.jobs is a plain AIJob[] (resolved on server); re-sync on invalidateAll
|
||||
$effect(() => {
|
||||
data.jobs.then((resolved) => { jobs = resolved; });
|
||||
jobs = data.jobs;
|
||||
});
|
||||
|
||||
// ── Live-poll while any job is in-flight ─────────────────────────────────────
|
||||
@@ -57,6 +57,7 @@
|
||||
// ── Cancel ────────────────────────────────────────────────────────────────────
|
||||
let cancellingIds = $state(new Set<string>());
|
||||
let cancelErrors: Record<string, string> = $state({});
|
||||
let cancellingAll = $state(false);
|
||||
|
||||
async function cancelJob(id: string) {
|
||||
if (cancellingIds.has(id)) return;
|
||||
@@ -77,6 +78,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelAllRunning() {
|
||||
if (cancellingAll) return;
|
||||
cancellingAll = true;
|
||||
const inFlight = jobs.filter((j) => j.status === 'running' || j.status === 'pending');
|
||||
await Promise.all(inFlight.map((j) => cancelJob(j.id)));
|
||||
cancellingAll = false;
|
||||
}
|
||||
|
||||
// ── Review & Apply (chapter-names jobs) ──────────────────────────────────────
|
||||
|
||||
interface ProposedTitle {
|
||||
@@ -411,7 +420,9 @@
|
||||
|
||||
function fmtDate(s: string | undefined) {
|
||||
if (!s) return '—';
|
||||
return new Date(s).toLocaleString(undefined, {
|
||||
const d = new Date(s);
|
||||
if (d.getFullYear() < 2000) return '—';
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
@@ -482,6 +493,27 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Bulk actions -->
|
||||
{#if stats.running + stats.pending > 0}
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
onclick={cancelAllRunning}
|
||||
disabled={cancellingAll}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{#if cancellingAll}
|
||||
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
|
||||
</svg>
|
||||
Cancelling…
|
||||
{:else}
|
||||
Cancel all in-flight ({stats.running + stats.pending})
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<input
|
||||
|
||||
@@ -169,7 +169,9 @@
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString();
|
||||
const d = new Date(dateStr);
|
||||
if (d.getFullYear() < 2000) return '-';
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function statusColor(status: string) {
|
||||
|
||||
@@ -47,9 +47,31 @@
|
||||
</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">Notifications</h1>
|
||||
<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}
|
||||
|
||||
7
ui/src/routes/admin/site-theme/+page.server.ts
Normal file
7
ui/src/routes/admin/site-theme/+page.server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getSiteConfig } from '$lib/server/pocketbase';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const config = await getSiteConfig();
|
||||
return { config };
|
||||
};
|
||||
160
ui/src/routes/admin/site-theme/+page.svelte
Normal file
160
ui/src/routes/admin/site-theme/+page.svelte
Normal file
@@ -0,0 +1,160 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
type Decoration = 'snow' | 'sakura' | 'fireflies' | 'leaves' | 'stars' | null;
|
||||
type LogoAnimation = 'none' | 'glow' | 'rainbow' | 'pulse' | 'shimmer';
|
||||
|
||||
let decoration = $state<Decoration>(data.config.decoration);
|
||||
let logoAnimation = $state<LogoAnimation>(data.config.logoAnimation);
|
||||
let eventLabel = $state(data.config.eventLabel ?? '');
|
||||
|
||||
let saving = $state(false);
|
||||
let saved = $state(false);
|
||||
let errMsg = $state('');
|
||||
|
||||
const DECORATIONS: { id: Decoration; label: string; emoji: string; desc: string }[] = [
|
||||
{ id: null, label: 'Off', emoji: '✕', desc: 'No decoration' },
|
||||
{ id: 'snow', label: 'Snow', emoji: '❄️', desc: 'Falling snowflakes — winter' },
|
||||
{ id: 'sakura', label: 'Sakura', emoji: '🌸', desc: 'Cherry blossom petals — spring' },
|
||||
{ id: 'fireflies', label: 'Fireflies', emoji: '✨', desc: 'Glowing fireflies — summer' },
|
||||
{ id: 'leaves', label: 'Leaves', emoji: '🍂', desc: 'Falling autumn leaves — fall' },
|
||||
{ id: 'stars', label: 'Stars', emoji: '⭐', desc: 'Twinkling stars — events / fantasy' },
|
||||
];
|
||||
|
||||
const LOGO_ANIMATIONS: { id: LogoAnimation; label: string; desc: string }[] = [
|
||||
{ id: 'none', label: 'None', desc: 'Default brand colour, no animation' },
|
||||
{ id: 'glow', label: 'Glow', desc: 'Soft pulsing amber glow' },
|
||||
{ id: 'shimmer', label: 'Shimmer', desc: 'Left-to-right shine sweep' },
|
||||
{ id: 'pulse', label: 'Pulse', desc: 'Subtle scale pulse' },
|
||||
{ id: 'rainbow', label: 'Rainbow', desc: 'Slow hue-rotate colour cycle' },
|
||||
];
|
||||
|
||||
async function save() {
|
||||
saving = true; saved = false; errMsg = '';
|
||||
try {
|
||||
const res = await fetch('/api/site-config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ decoration, logoAnimation, eventLabel }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
errMsg = d.message ?? `Error ${res.status}`;
|
||||
} else {
|
||||
saved = true;
|
||||
setTimeout(() => { saved = false; }, 3000);
|
||||
}
|
||||
} catch (e) {
|
||||
errMsg = String(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Site Theme — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-(--color-text) mb-1">Site Theme</h1>
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
Control seasonal decorations and the nav logo animation globally.
|
||||
Changes take effect for all users within ~60 seconds (server cache TTL).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ── Decoration ────────────────────────────────────────────────────── -->
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-(--color-text) uppercase tracking-widest mb-3">Particle Decoration</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{#each DECORATIONS as d}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { decoration = d.id; }}
|
||||
class="flex items-start gap-3 p-3 rounded-lg border text-left transition-all
|
||||
{decoration === d.id
|
||||
? 'border-(--color-brand) bg-(--color-surface-2) text-(--color-text)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2)/40 text-(--color-muted) hover:border-(--color-brand)/40 hover:text-(--color-text)'}"
|
||||
>
|
||||
<span class="text-xl leading-none shrink-0 mt-0.5">{d.emoji}</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-semibold leading-snug">{d.label}</p>
|
||||
<p class="text-xs opacity-70 leading-snug mt-0.5">{d.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Logo Animation ────────────────────────────────────────────────── -->
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-(--color-text) uppercase tracking-widest mb-3">Logo Animation</h2>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each LOGO_ANIMATIONS as a}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { logoAnimation = a.id; }}
|
||||
class="flex items-center gap-4 p-3 rounded-lg border text-left transition-all
|
||||
{logoAnimation === a.id
|
||||
? 'border-(--color-brand) bg-(--color-surface-2) text-(--color-text)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2)/40 text-(--color-muted) hover:border-(--color-brand)/40 hover:text-(--color-text)'}"
|
||||
>
|
||||
<!-- Preview of the logo text with the animation class applied -->
|
||||
<span class="font-bold text-lg tracking-tight w-24 shrink-0 text-(--color-brand)
|
||||
{a.id === 'glow' ? 'logo-anim-glow' : ''}
|
||||
{a.id === 'shimmer' ? 'logo-anim-shimmer' : ''}
|
||||
{a.id === 'pulse' ? 'logo-anim-pulse' : ''}
|
||||
{a.id === 'rainbow' ? 'logo-anim-rainbow' : ''}
|
||||
">libnovel</span>
|
||||
<div>
|
||||
<p class="text-sm font-semibold">{a.label}</p>
|
||||
<p class="text-xs opacity-70">{a.desc}</p>
|
||||
</div>
|
||||
{#if logoAnimation === a.id}
|
||||
<svg class="w-4 h-4 ml-auto shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Event Label ───────────────────────────────────────────────────── -->
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-(--color-text) uppercase tracking-widest mb-1">Event Label <span class="normal-case font-normal text-(--color-muted)">(optional)</span></h2>
|
||||
<p class="text-xs text-(--color-muted) mb-3">Short text shown as a small badge next to the logo, e.g. "Winter 2025" or "Sakura Festival". Leave blank to hide.</p>
|
||||
<input
|
||||
type="text"
|
||||
maxlength="64"
|
||||
placeholder="e.g. Winter 2025"
|
||||
bind:value={eventLabel}
|
||||
class="w-full px-3 py-2 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-text) text-sm placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand)/60"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- ── Save ──────────────────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={save}
|
||||
disabled={saving}
|
||||
class="px-5 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
{#if saved}
|
||||
<span class="text-sm text-green-400 flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
||||
Saved
|
||||
</span>
|
||||
{/if}
|
||||
{#if errMsg}
|
||||
<span class="text-sm text-red-400">{errMsg}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
57
ui/src/routes/api/site-config/+server.ts
Normal file
57
ui/src/routes/api/site-config/+server.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getSiteConfig, saveSiteConfig } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/site-config
|
||||
* Public — returns current site-wide decoration/animation settings.
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
const config = await getSiteConfig();
|
||||
return json(config);
|
||||
} catch (e) {
|
||||
log.error('site-config', 'GET failed', { err: String(e) });
|
||||
error(500, 'Failed to load site config');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /api/site-config
|
||||
* Admin only — updates decoration + logoAnimation + eventLabel.
|
||||
*/
|
||||
export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!body) error(400, 'Invalid JSON body');
|
||||
|
||||
const validDecorations = ['snow', 'sakura', 'fireflies', 'leaves', 'stars', null];
|
||||
if (body.decoration !== undefined && !validDecorations.includes(body.decoration)) {
|
||||
error(400, `Invalid decoration — must be one of: ${validDecorations.filter(Boolean).join(', ')}, or null`);
|
||||
}
|
||||
|
||||
const validLogoAnimations = ['none', 'glow', 'rainbow', 'pulse', 'shimmer'];
|
||||
if (body.logoAnimation !== undefined && !validLogoAnimations.includes(body.logoAnimation)) {
|
||||
error(400, `Invalid logoAnimation — must be one of: ${validLogoAnimations.join(', ')}`);
|
||||
}
|
||||
|
||||
if (body.eventLabel !== undefined && typeof body.eventLabel !== 'string') {
|
||||
error(400, 'eventLabel must be a string');
|
||||
}
|
||||
|
||||
try {
|
||||
await saveSiteConfig({
|
||||
decoration: body.decoration ?? null,
|
||||
logoAnimation: body.logoAnimation ?? 'none',
|
||||
eventLabel: typeof body.eventLabel === 'string' ? body.eventLabel.slice(0, 64) : '',
|
||||
});
|
||||
return json({ ok: true });
|
||||
} catch (e) {
|
||||
log.error('site-config', 'PUT failed', { err: String(e) });
|
||||
error(500, 'Failed to save site config');
|
||||
}
|
||||
};
|
||||
@@ -23,9 +23,12 @@ export const GET: RequestHandler = async ({ params, url }) => {
|
||||
return new Response(null, { status: res.status });
|
||||
}
|
||||
const data = await res.json();
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
// Forward the immutable cache header from the backend so browsers and CDNs
|
||||
// can cache translated chapter content without hitting MinIO on every load.
|
||||
const cc = res.headers.get('Cache-Control');
|
||||
if (cc) headers['Cache-Control'] = cc;
|
||||
return new Response(JSON.stringify(data), { headers });
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, locals }) => {
|
||||
|
||||
@@ -16,6 +16,7 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
const sort = url.searchParams.get('sort') ?? 'popular';
|
||||
const status = url.searchParams.get('status') ?? 'all';
|
||||
const q = url.searchParams.get('q') ?? '';
|
||||
const audioOnly = url.searchParams.get('audio') === '1';
|
||||
|
||||
const params = new URLSearchParams({ page, genre, sort, status });
|
||||
if (q.trim().length >= 2) {
|
||||
@@ -64,7 +65,8 @@ export const load: PageServerLoad = async ({ url, locals }) => {
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
searchQuery: q.trim().length >= 2 ? q.trim() : '',
|
||||
searchLocalCount: 0,
|
||||
searchRemoteCount: 0
|
||||
searchRemoteCount: 0,
|
||||
audioOnly
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -21,11 +21,13 @@
|
||||
});
|
||||
|
||||
function applyFilters() {
|
||||
filtersOpen = false;
|
||||
const params = new URLSearchParams();
|
||||
params.set('sort', filterSort);
|
||||
params.set('genre', filterGenre);
|
||||
params.set('status', filterStatus);
|
||||
params.set('page', '1');
|
||||
if (filterAudioOnly) params.set('audio', '1');
|
||||
goto(`/catalogue?${params.toString()}`);
|
||||
}
|
||||
|
||||
@@ -215,7 +217,7 @@
|
||||
|
||||
// ── Audio-available set ───────────────────────────────────────────────────
|
||||
let audioSlugs = $state<Set<string>>(new Set());
|
||||
let filterAudioOnly = $state(false);
|
||||
let filterAudioOnly = $state(untrack(() => data.audioOnly));
|
||||
|
||||
$effect(() => {
|
||||
fetch('/api/audio/slugs')
|
||||
@@ -224,6 +226,17 @@
|
||||
.catch(() => { /* non-critical */ });
|
||||
});
|
||||
|
||||
function toggleAudio() {
|
||||
filterAudioOnly = !filterAudioOnly;
|
||||
const u = new URL(window.location.href);
|
||||
if (filterAudioOnly) {
|
||||
u.searchParams.set('audio', '1');
|
||||
} else {
|
||||
u.searchParams.delete('audio');
|
||||
}
|
||||
history.replaceState({}, '', u.toString());
|
||||
}
|
||||
|
||||
const displayedNovels = $derived(
|
||||
filterAudioOnly ? novels.filter((n) => audioSlugs.has(n.slug)) : novels
|
||||
);
|
||||
@@ -249,7 +262,7 @@
|
||||
{m.catalogue_rank_no_data_body()}
|
||||
{/if}
|
||||
{:else}
|
||||
{m.catalogue_browse_source()}
|
||||
{m.catalogue_browse_source()}{#if data.total > 0} <span class="text-(--color-muted) text-xs">{data.total.toLocaleString()} novels</span>{/if}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -349,7 +362,7 @@
|
||||
<!-- Audio-only filter toggle -->
|
||||
{#if audioSlugs.size > 0}
|
||||
<button
|
||||
onclick={() => (filterAudioOnly = !filterAudioOnly)}
|
||||
onclick={toggleAudio}
|
||||
title="Show only books with audio"
|
||||
class="flex items-center gap-1.5 px-2.5 py-2 rounded border text-sm transition-colors shrink-0
|
||||
{filterAudioOnly
|
||||
@@ -503,7 +516,7 @@
|
||||
{m.catalogue_rank_run_scrape_user()}
|
||||
{/if}
|
||||
{:else if filterAudioOnly}
|
||||
<button onclick={() => (filterAudioOnly = false)} class="text-(--color-brand) hover:underline">Clear audio filter</button>
|
||||
<button onclick={toggleAudio} class="text-(--color-brand) hover:underline">Clear audio filter</button>
|
||||
{:else}
|
||||
{m.catalogue_no_results_filters()}
|
||||
{/if}
|
||||
@@ -531,11 +544,8 @@
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
|
||||
<svg class="w-12 h-12" 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 class="w-full h-full flex items-center justify-center bg-(--color-surface-3)">
|
||||
<span class="text-5xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if novel.rank}
|
||||
@@ -622,11 +632,8 @@
|
||||
{#if novel.cover}
|
||||
<img src={novel.cover} alt={novel.title} class="w-full h-full object-cover" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
|
||||
<svg class="w-5 h-5" 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 class="w-full h-full flex items-center justify-center bg-(--color-surface-3)">
|
||||
<span class="text-xl font-bold text-(--color-muted) select-none opacity-50">{novel.title.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isLoading}
|
||||
@@ -688,6 +695,8 @@
|
||||
<span class="text-xs text-emerald-400 font-medium">{m.catalogue_scrape_queued_badge()}</span>
|
||||
{:else if scrapeResult[novel.slug] === 'busy'}
|
||||
<span class="text-xs text-yellow-400 font-medium">{m.catalogue_scrape_busy_list()}</span>
|
||||
{:else if scrapeResult[novel.slug] === 'forbidden'}
|
||||
<span class="text-xs text-(--color-danger) font-medium">{m.catalogue_scrape_forbidden_badge()}</span>
|
||||
{:else if scrapeResult[novel.slug] === 'error'}
|
||||
<span class="text-xs text-(--color-danger) font-medium">{m.common_error()}</span>
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user