Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8d7108753 | ||
|
|
90dbecfa17 | ||
|
|
2deb306419 | ||
|
|
fd283bf6c6 | ||
|
|
3154a22500 | ||
|
|
61e0d98057 | ||
|
|
601c26d436 | ||
|
|
4a267d8fd8 | ||
|
|
c9478a67fb | ||
|
|
1b4835daeb | ||
|
|
c9c12fc4a8 | ||
|
|
dd35024d02 | ||
|
|
4b8104f087 | ||
|
|
5da880d189 | ||
|
|
98631df47a | ||
|
|
83b3dccc41 | ||
|
|
588e455aae | ||
|
|
28ac8d8826 | ||
|
|
0a3a61a3ef | ||
|
|
7a2a4fc755 | ||
|
|
801928aadf | ||
|
|
040072c3f5 | ||
|
|
6a76e97a67 | ||
|
|
71f79c8e02 | ||
|
|
5ee4a06654 | ||
|
|
63b286d0a4 | ||
|
|
d3f06c5c40 | ||
|
|
e71ddc2f8b | ||
|
|
b783dae5f4 | ||
|
|
dcf40197d4 | ||
|
|
9dae5e7cc0 | ||
|
|
908f5679fd | ||
|
|
f75292f531 | ||
|
|
2cf0528730 | ||
|
|
428b57732e | ||
|
|
61e77e3e28 | ||
|
|
b363c151a5 | ||
|
|
aef9e04419 |
@@ -2,20 +2,14 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "master"]
|
||||
paths:
|
||||
- "backend/**"
|
||||
- "ui/**"
|
||||
- "caddy/**"
|
||||
- "docker-compose.yml"
|
||||
- ".gitea/workflows/ci.yaml"
|
||||
pull_request:
|
||||
branches: ["main", "master"]
|
||||
paths:
|
||||
- "backend/**"
|
||||
- "ui/**"
|
||||
- "caddy/**"
|
||||
- "docker-compose.yml"
|
||||
- ".gitea/workflows/ci.yaml"
|
||||
|
||||
concurrency:
|
||||
@@ -23,10 +17,13 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ── backend: vet & test ───────────────────────────────────────────────────────
|
||||
test-backend:
|
||||
name: Test backend
|
||||
# ── Go: vet + build + test ────────────────────────────────────────────────
|
||||
backend:
|
||||
name: Backend
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -36,16 +33,23 @@ jobs:
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: go vet
|
||||
working-directory: backend
|
||||
run: go vet ./...
|
||||
|
||||
- name: Build backend
|
||||
run: go build -o /dev/null ./cmd/backend
|
||||
|
||||
- name: Build runner
|
||||
run: go build -o /dev/null ./cmd/runner
|
||||
|
||||
- name: Build healthcheck
|
||||
run: go build -o /dev/null ./cmd/healthcheck
|
||||
|
||||
- name: Run tests
|
||||
working-directory: backend
|
||||
run: go test -short -race -count=1 -timeout=60s ./...
|
||||
|
||||
# ── ui: type-check & build ────────────────────────────────────────────────────
|
||||
check-ui:
|
||||
name: Check ui
|
||||
# ── UI: type-check + build ────────────────────────────────────────────────
|
||||
ui:
|
||||
name: UI
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
@@ -67,57 +71,3 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
# ── docker: validate Dockerfiles build (no push) ──────────────────────────────
|
||||
docker-backend:
|
||||
name: Docker / backend
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-backend]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: backend
|
||||
target: backend
|
||||
push: false
|
||||
|
||||
docker-runner:
|
||||
name: Docker / runner
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-backend]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: backend
|
||||
target: runner
|
||||
push: false
|
||||
|
||||
docker-ui:
|
||||
name: Docker / ui
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check-ui]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ui
|
||||
push: false
|
||||
|
||||
docker-caddy:
|
||||
name: Docker / caddy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: caddy
|
||||
push: false
|
||||
|
||||
@@ -136,52 +136,51 @@ jobs:
|
||||
cache-to: type=inline
|
||||
|
||||
# ── ui: source map upload ─────────────────────────────────────────────────────
|
||||
# Builds the UI with source maps and uploads them to GlitchTip so that error
|
||||
# stack traces resolve to original .svelte/.ts file names and line numbers.
|
||||
# Runs in parallel with docker-ui (both need check-ui to pass first).
|
||||
upload-sourcemaps:
|
||||
name: Upload source maps
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check-ui]
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build with source maps
|
||||
run: npm run build
|
||||
|
||||
- name: Download glitchtip-cli
|
||||
run: |
|
||||
curl -L "https://gitlab.com/glitchtip/glitchtip-cli/-/jobs/artifacts/v0.1.0/raw/artifacts/glitchtip-cli-linux-x86_64?job=build-linux-x86_64" \
|
||||
-o /usr/local/bin/glitchtip-cli
|
||||
chmod +x /usr/local/bin/glitchtip-cli
|
||||
|
||||
- name: Inject debug IDs into build artifacts
|
||||
run: glitchtip-cli sourcemaps inject ./build
|
||||
env:
|
||||
SENTRY_URL: https://errors.libnovel.cc/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
SENTRY_ORG: libnovel
|
||||
SENTRY_PROJECT: libnovel-ui
|
||||
|
||||
- name: Upload source maps to GlitchTip
|
||||
run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }}
|
||||
env:
|
||||
SENTRY_URL: https://errors.libnovel.cc/
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
SENTRY_ORG: libnovel
|
||||
SENTRY_PROJECT: libnovel-ui
|
||||
# Commented out: GlitchTip project/auth token needs to be recreated after
|
||||
# the GlitchTip DB wipe. Re-enable once GLITCHTIP_AUTH_TOKEN is updated.
|
||||
# upload-sourcemaps:
|
||||
# name: Upload source maps
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: [check-ui]
|
||||
# defaults:
|
||||
# run:
|
||||
# working-directory: ui
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
#
|
||||
# - uses: actions/setup-node@v4
|
||||
# with:
|
||||
# node-version: "22"
|
||||
# cache: npm
|
||||
# cache-dependency-path: ui/package-lock.json
|
||||
#
|
||||
# - name: Install dependencies
|
||||
# run: npm ci
|
||||
#
|
||||
# - name: Build with source maps
|
||||
# run: npm run build
|
||||
#
|
||||
# - name: Download glitchtip-cli
|
||||
# run: |
|
||||
# curl -L "https://gitlab.com/glitchtip/glitchtip-cli/-/jobs/artifacts/v0.1.0/raw/artifacts/glitchtip-cli-linux-x86_64?job=build-linux-x86_64" \
|
||||
# -o /usr/local/bin/glitchtip-cli
|
||||
# chmod +x /usr/local/bin/glitchtip-cli
|
||||
#
|
||||
# - name: Inject debug IDs into build artifacts
|
||||
# run: glitchtip-cli sourcemaps inject ./build
|
||||
# env:
|
||||
# SENTRY_URL: https://errors.libnovel.cc/
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
# SENTRY_ORG: libnovel
|
||||
# SENTRY_PROJECT: libnovel-ui
|
||||
#
|
||||
# - name: Upload source maps to GlitchTip
|
||||
# run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }}
|
||||
# env:
|
||||
# SENTRY_URL: https://errors.libnovel.cc/
|
||||
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
|
||||
# SENTRY_ORG: libnovel
|
||||
# SENTRY_PROJECT: libnovel-ui
|
||||
|
||||
# ── docker: ui ────────────────────────────────────────────────────────────────
|
||||
docker-ui:
|
||||
@@ -191,6 +190,17 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Fetch releases from Gitea API
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RESPONSE=$(curl -sfL \
|
||||
-H "Accept: application/json" \
|
||||
"http://gitea.kalekber.cc/api/v1/repos/kamil/libnovel/releases?limit=50&page=1")
|
||||
# Validate JSON before writing — fails hard if response is not a JSON array
|
||||
COUNT=$(echo "$RESPONSE" | jq 'if type == "array" then length else error("expected array, got \(type)") end')
|
||||
echo "$RESPONSE" > ui/static/releases.json
|
||||
echo "Fetched $COUNT releases"
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
@@ -219,6 +229,7 @@ jobs:
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta.outputs.version }}
|
||||
BUILD_COMMIT=${{ gitea.sha }}
|
||||
BUILD_TIME=${{ gitea.event.head_commit.timestamp }}
|
||||
cache-from: type=registry,ref=${{ secrets.DOCKER_USER }}/libnovel-ui:latest
|
||||
cache-to: type=inline
|
||||
|
||||
@@ -261,14 +272,14 @@ jobs:
|
||||
release:
|
||||
name: Gitea Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker-backend, docker-runner, docker-ui, docker-caddy, upload-sourcemaps]
|
||||
needs: [docker-backend, docker-runner, docker-ui, docker-caddy]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create release
|
||||
uses: actions/gitea-release-action@v1
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
generate_release_notes: true
|
||||
|
||||
61
Caddyfile
61
Caddyfile
@@ -56,6 +56,22 @@
|
||||
ticker_interval 15s
|
||||
}
|
||||
|
||||
# ── Redis TCP proxy via layer4 ────────────────────────────────────────────
|
||||
# Exposes homelab Redis over TLS for Asynq job enqueueing from the backend.
|
||||
# Listens on :6380 (all interfaces). TLS is terminated here using the cert
|
||||
# for redis.libnovel.cc; traffic is proxied to the homelab Redis instance.
|
||||
# Requires the caddy-l4 module in the custom Caddy build.
|
||||
layer4 {
|
||||
:6380 {
|
||||
route {
|
||||
tls
|
||||
proxy {
|
||||
upstream {$HOMELAB_REDIS_ADDR:192.168.0.109:6379}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(security_headers) {
|
||||
header {
|
||||
@@ -170,12 +186,31 @@
|
||||
# ── SvelteKit UI (catch-all — includes all remaining /api/* routes) ───────
|
||||
handle {
|
||||
reverse_proxy ui:3000 {
|
||||
}
|
||||
# Active health check: Caddy polls /health every 5 s and marks the
|
||||
# upstream down immediately when it fails. Combined with
|
||||
# lb_try_duration this means Watchtower container replacements
|
||||
# show the maintenance page within a few seconds instead of
|
||||
# hanging or returning a raw connection error to the browser.
|
||||
health_uri /health
|
||||
health_interval 5s
|
||||
health_timeout 2s
|
||||
health_status 200
|
||||
|
||||
# If the upstream is down, fail fast (don't retry for longer than
|
||||
# 3 s) and let Caddy's handle_errors 502/503 take over.
|
||||
lb_try_duration 3s
|
||||
}
|
||||
}
|
||||
|
||||
# ── Caddy-level error pages ───────────────────────────────────────────────
|
||||
# These fire when the upstream (backend or ui) is completely unreachable.
|
||||
# SvelteKit's own +error.svelte handles application-level errors (404, 500).
|
||||
handle_errors 404 {
|
||||
root * /srv/errors
|
||||
rewrite * /404.html
|
||||
file_server
|
||||
}
|
||||
handle_errors 502 {
|
||||
root * /srv/errors
|
||||
rewrite * /502.html
|
||||
file_server
|
||||
@@ -234,27 +269,3 @@ search.libnovel.cc {
|
||||
reverse_proxy meilisearch:7700
|
||||
}
|
||||
}
|
||||
# ── Redis TCP proxy: exposes homelab Redis over TLS for Asynq ─────────────────
|
||||
# The backend (prod) connects to rediss://redis.libnovel.cc:6380 to enqueue
|
||||
# Asynq jobs. Caddy terminates TLS (Let's Encrypt cert for redis.libnovel.cc)
|
||||
# and proxies the raw TCP stream to the homelab Redis via this reverse proxy.
|
||||
#
|
||||
# NOTE: Redis is NOT running on the prod server — it runs on the homelab
|
||||
# (192.168.0.109:6379) and is exposed to the internet via this Caddy proxy.
|
||||
# The homelab Redis is protected by REDIS_PASSWORD (requirepass).
|
||||
#
|
||||
# Caddy layer4 app handles this; requires the caddy-l4 module in the build.
|
||||
{
|
||||
layer4 {
|
||||
redis.libnovel.cc:6380 {
|
||||
route {
|
||||
tls
|
||||
proxy {
|
||||
# Homelab Redis — replace with actual homelab IP or FQDN
|
||||
upstream {$HOMELAB_REDIS_ADDR:192.168.0.109:6379}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,14 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
-o /out/healthcheck ./cmd/healthcheck
|
||||
|
||||
# ── backend service ──────────────────────────────────────────────────────────
|
||||
FROM gcr.io/distroless/static:nonroot AS backend
|
||||
# Uses Alpine (not distroless) so ffmpeg is available for on-demand voice
|
||||
# sample generation via pocket-tts (WAV→MP3 transcoding).
|
||||
FROM alpine:3.21 AS backend
|
||||
RUN apk add --no-cache ffmpeg ca-certificates && \
|
||||
addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||
COPY --from=builder /out/healthcheck /healthcheck
|
||||
COPY --from=builder /out/backend /backend
|
||||
USER appuser
|
||||
ENTRYPOINT ["/backend"]
|
||||
|
||||
# ── runner service ───────────────────────────────────────────────────────────
|
||||
|
||||
BIN
backend/backend
BIN
backend/backend
Binary file not shown.
@@ -150,18 +150,19 @@ func run() error {
|
||||
Commit: commit,
|
||||
},
|
||||
backend.Dependencies{
|
||||
BookReader: store,
|
||||
RankingStore: store,
|
||||
AudioStore: store,
|
||||
PresignStore: store,
|
||||
ProgressStore: store,
|
||||
CoverStore: store,
|
||||
Producer: producer,
|
||||
TaskReader: store,
|
||||
SearchIndex: searchIndex,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
Log: log,
|
||||
BookReader: store,
|
||||
RankingStore: store,
|
||||
AudioStore: store,
|
||||
TranslationStore: store,
|
||||
PresignStore: store,
|
||||
ProgressStore: store,
|
||||
CoverStore: store,
|
||||
Producer: producer,
|
||||
TaskReader: store,
|
||||
SearchIndex: searchIndex,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
Log: log,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/libnovel/backend/internal/browser"
|
||||
"github.com/libnovel/backend/internal/config"
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
"github.com/libnovel/backend/internal/libretranslate"
|
||||
"github.com/libnovel/backend/internal/meili"
|
||||
"github.com/libnovel/backend/internal/novelfire"
|
||||
"github.com/libnovel/backend/internal/otelsetup"
|
||||
@@ -128,6 +129,14 @@ func run() error {
|
||||
log.Warn("POCKET_TTS_URL not set — pocket-tts voice tasks will fail")
|
||||
}
|
||||
|
||||
// ── LibreTranslate ──────────────────────────────────────────────────────
|
||||
ltClient := libretranslate.New(cfg.LibreTranslate.URL, cfg.LibreTranslate.APIKey)
|
||||
if ltClient != nil {
|
||||
log.Info("libretranslate enabled", "url", cfg.LibreTranslate.URL)
|
||||
} else {
|
||||
log.Info("LIBRETRANSLATE_URL not set — machine translation disabled")
|
||||
}
|
||||
|
||||
// ── Meilisearch ─────────────────────────────────────────────────────────
|
||||
var searchIndex meili.Client
|
||||
if cfg.Meilisearch.URL != "" {
|
||||
@@ -149,9 +158,11 @@ func run() error {
|
||||
PollInterval: cfg.Runner.PollInterval,
|
||||
MaxConcurrentScrape: cfg.Runner.MaxConcurrentScrape,
|
||||
MaxConcurrentAudio: cfg.Runner.MaxConcurrentAudio,
|
||||
MaxConcurrentTranslation: cfg.Runner.MaxConcurrentTranslation,
|
||||
OrchestratorWorkers: workers,
|
||||
MetricsAddr: cfg.Runner.MetricsAddr,
|
||||
CatalogueRefreshInterval: cfg.Runner.CatalogueRefreshInterval,
|
||||
CatalogueRequestDelay: cfg.Runner.CatalogueRequestDelay,
|
||||
SkipInitialCatalogueRefresh: cfg.Runner.SkipInitialCatalogueRefresh,
|
||||
RedisAddr: cfg.Redis.Addr,
|
||||
RedisPassword: cfg.Redis.Password,
|
||||
@@ -169,16 +180,18 @@ func run() error {
|
||||
}
|
||||
|
||||
deps := runner.Dependencies{
|
||||
Consumer: consumer,
|
||||
BookWriter: store,
|
||||
BookReader: store,
|
||||
AudioStore: store,
|
||||
CoverStore: store,
|
||||
SearchIndex: searchIndex,
|
||||
Novel: novel,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
Log: log,
|
||||
Consumer: consumer,
|
||||
BookWriter: store,
|
||||
BookReader: store,
|
||||
AudioStore: store,
|
||||
CoverStore: store,
|
||||
TranslationStore: store,
|
||||
SearchIndex: searchIndex,
|
||||
Novel: novel,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
LibreTranslate: ltClient,
|
||||
Log: log,
|
||||
}
|
||||
r := runner.New(rCfg, deps)
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ require (
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
|
||||
@@ -84,6 +84,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k=
|
||||
|
||||
@@ -37,6 +37,10 @@ func (c *Consumer) FinishAudioTask(ctx context.Context, id string, result domain
|
||||
return c.pb.FinishAudioTask(ctx, id, result)
|
||||
}
|
||||
|
||||
func (c *Consumer) FinishTranslationTask(ctx context.Context, id string, result domain.TranslationResult) error {
|
||||
return c.pb.FinishTranslationTask(ctx, id, result)
|
||||
}
|
||||
|
||||
func (c *Consumer) FailTask(ctx context.Context, id, errMsg string) error {
|
||||
return c.pb.FailTask(ctx, id, errMsg)
|
||||
}
|
||||
@@ -51,6 +55,10 @@ func (c *Consumer) ClaimNextAudioTask(_ context.Context, _ string) (domain.Audio
|
||||
return domain.AudioTask{}, false, nil
|
||||
}
|
||||
|
||||
func (c *Consumer) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
|
||||
return domain.TranslationTask{}, false, nil
|
||||
}
|
||||
|
||||
func (c *Consumer) HeartbeatTask(_ context.Context, _ string) error { return nil }
|
||||
|
||||
func (c *Consumer) ReapStaleTasks(_ context.Context, _ time.Duration) (int, error) { return 0, nil }
|
||||
|
||||
@@ -73,6 +73,12 @@ func (p *Producer) CreateAudioTask(ctx context.Context, slug string, chapter int
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// CreateTranslationTask creates a PocketBase record. Translation tasks are
|
||||
// not currently dispatched via Asynq — the runner picks them up via polling.
|
||||
func (p *Producer) CreateTranslationTask(ctx context.Context, slug string, chapter int, lang string) (string, error) {
|
||||
return p.pb.CreateTranslationTask(ctx, slug, chapter, lang)
|
||||
}
|
||||
|
||||
// CancelTask delegates to PocketBase; Asynq jobs may already be running and
|
||||
// cannot be reliably cancelled, so we only update the audit record.
|
||||
func (p *Producer) CancelTask(ctx context.Context, id string) error {
|
||||
|
||||
@@ -32,6 +32,7 @@ package backend
|
||||
// directly (no runner task, no store writes). Used for unscraped books.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -49,6 +50,7 @@ import (
|
||||
"github.com/libnovel/backend/internal/novelfire/htmlutil"
|
||||
"github.com/libnovel/backend/internal/pockettts"
|
||||
"github.com/libnovel/backend/internal/scraper"
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -701,9 +703,252 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, presignURL, http.StatusFound)
|
||||
}
|
||||
|
||||
// ── Voices ─────────────────────────────────────────────────────────────────────
|
||||
// ── Translation ────────────────────────────────────────────────────────────────
|
||||
|
||||
// handleVoices handles GET /api/voices.
|
||||
// supportedTranslationLangs is the set of target locales the backend accepts.
|
||||
// Source is always "en".
|
||||
var supportedTranslationLangs = map[string]bool{
|
||||
"ru": true, "id": true, "pt": true, "fr": true,
|
||||
}
|
||||
|
||||
// handleTranslationGenerate handles POST /api/translation/{slug}/{n}.
|
||||
// Query params: lang (required, one of ru|id|pt|fr)
|
||||
//
|
||||
// Returns 200 immediately if translation already exists in MinIO.
|
||||
// Returns 202 with task_id if a new task was created.
|
||||
// Returns 503 if TranslationStore is nil (feature disabled).
|
||||
func (s *Server) handleTranslationGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TranslationStore == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "machine translation not configured")
|
||||
return
|
||||
}
|
||||
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 {
|
||||
jsonError(w, http.StatusBadRequest, "invalid chapter")
|
||||
return
|
||||
}
|
||||
|
||||
lang := r.URL.Query().Get("lang")
|
||||
if !supportedTranslationLangs[lang] {
|
||||
jsonError(w, http.StatusBadRequest, "unsupported lang; use ru, id, pt, or fr")
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, lang)
|
||||
|
||||
// Fast path: translation already in MinIO
|
||||
key := s.deps.TranslationStore.TranslationObjectKey(lang, slug, n)
|
||||
if s.deps.TranslationStore.TranslationExists(r.Context(), key) {
|
||||
writeJSON(w, 0, map[string]string{"status": "done", "lang": lang})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if a task is already pending/running
|
||||
task, found, _ := s.deps.TaskReader.GetTranslationTask(r.Context(), cacheKey)
|
||||
if found && (task.Status == domain.TaskStatusPending || task.Status == domain.TaskStatusRunning) {
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{
|
||||
"task_id": task.ID,
|
||||
"status": string(task.Status),
|
||||
"lang": lang,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new translation task
|
||||
taskID, err := s.deps.Producer.CreateTranslationTask(r.Context(), slug, n, lang)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleTranslationGenerate: CreateTranslationTask failed", "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to create translation task")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{
|
||||
"task_id": taskID,
|
||||
"status": "pending",
|
||||
"lang": lang,
|
||||
})
|
||||
}
|
||||
|
||||
// handleTranslationStatus handles GET /api/translation/status/{slug}/{n}.
|
||||
// Query params: lang (required)
|
||||
func (s *Server) handleTranslationStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TranslationStore == nil {
|
||||
writeJSON(w, 0, map[string]string{"status": "unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 || slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "invalid params")
|
||||
return
|
||||
}
|
||||
|
||||
lang := r.URL.Query().Get("lang")
|
||||
if !supportedTranslationLangs[lang] {
|
||||
jsonError(w, http.StatusBadRequest, "unsupported lang")
|
||||
return
|
||||
}
|
||||
|
||||
// Fast path: translation exists in MinIO
|
||||
key := s.deps.TranslationStore.TranslationObjectKey(lang, slug, n)
|
||||
if s.deps.TranslationStore.TranslationExists(r.Context(), key) {
|
||||
writeJSON(w, 0, map[string]string{"status": "done", "lang": lang})
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, lang)
|
||||
task, found, _ := s.deps.TaskReader.GetTranslationTask(r.Context(), cacheKey)
|
||||
if !found {
|
||||
writeJSON(w, 0, map[string]string{"status": "idle", "lang": lang})
|
||||
return
|
||||
}
|
||||
|
||||
resp := map[string]string{
|
||||
"status": string(task.Status),
|
||||
"task_id": task.ID,
|
||||
"lang": lang,
|
||||
}
|
||||
if task.Status == domain.TaskStatusFailed && task.ErrorMessage != "" {
|
||||
resp["error"] = task.ErrorMessage
|
||||
}
|
||||
writeJSON(w, 0, resp)
|
||||
}
|
||||
|
||||
// handleTranslationRead handles GET /api/translation/{slug}/{n}.
|
||||
// Query params: lang (required)
|
||||
//
|
||||
// Returns {"html": "<p>...</p>", "lang": "ru"} from the MinIO-cached translation.
|
||||
// Returns 404 when the translation has not been generated yet.
|
||||
func (s *Server) handleTranslationRead(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.TranslationStore == nil {
|
||||
http.Error(w, `{"error":"machine translation not configured"}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 || slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "invalid params")
|
||||
return
|
||||
}
|
||||
|
||||
lang := r.URL.Query().Get("lang")
|
||||
if !supportedTranslationLangs[lang] {
|
||||
jsonError(w, http.StatusBadRequest, "unsupported lang")
|
||||
return
|
||||
}
|
||||
|
||||
key := s.deps.TranslationStore.TranslationObjectKey(lang, slug, n)
|
||||
md, err := s.deps.TranslationStore.GetTranslation(r.Context(), key)
|
||||
if err != nil {
|
||||
s.deps.Log.Warn("handleTranslationRead: translation not found", "slug", slug, "n", n, "lang", lang, "err", err)
|
||||
jsonError(w, http.StatusNotFound, "translation not available")
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := goldmark.Convert([]byte(md), &buf); err != nil {
|
||||
s.deps.Log.Error("handleTranslationRead: markdown conversion failed", "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to render translation")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]string{"html": buf.String(), "lang": lang})
|
||||
}
|
||||
|
||||
// handleAdminTranslationJobs handles GET /api/admin/translation/jobs.
|
||||
// Returns the full list of translation jobs sorted by started descending.
|
||||
func (s *Server) handleAdminTranslationJobs(w http.ResponseWriter, r *http.Request) {
|
||||
tasks, err := s.deps.TaskReader.ListTranslationTasks(r.Context())
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAdminTranslationJobs: ListTranslationTasks failed", "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "failed to list translation jobs")
|
||||
return
|
||||
}
|
||||
type jobRow struct {
|
||||
ID string `json:"id"`
|
||||
CacheKey string `json:"cache_key"`
|
||||
Slug string `json:"slug"`
|
||||
Chapter int `json:"chapter"`
|
||||
Lang string `json:"lang"`
|
||||
Status string `json:"status"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Started string `json:"started"`
|
||||
Finished string `json:"finished"`
|
||||
}
|
||||
rows := make([]jobRow, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
rows = append(rows, jobRow{
|
||||
ID: t.ID,
|
||||
CacheKey: t.CacheKey,
|
||||
Slug: t.Slug,
|
||||
Chapter: t.Chapter,
|
||||
Lang: t.Lang,
|
||||
Status: string(t.Status),
|
||||
WorkerID: t.WorkerID,
|
||||
ErrorMessage: t.ErrorMessage,
|
||||
Started: t.Started.Format(time.RFC3339),
|
||||
Finished: t.Finished.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
writeJSON(w, 0, map[string]any{"jobs": rows})
|
||||
}
|
||||
|
||||
// handleAdminTranslationBulk handles POST /api/admin/translation/bulk.
|
||||
// Body: {"slug": "...", "lang": "ru", "from": 1, "to": 50}
|
||||
// Enqueues one translation task per chapter in the range [from, to] inclusive.
|
||||
func (s *Server) handleAdminTranslationBulk(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Slug string `json:"slug"`
|
||||
Lang string `json:"lang"`
|
||||
From int `json:"from"`
|
||||
To int `json:"to"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if body.Slug == "" {
|
||||
jsonError(w, http.StatusBadRequest, "slug is required")
|
||||
return
|
||||
}
|
||||
if !supportedTranslationLangs[body.Lang] {
|
||||
jsonError(w, http.StatusBadRequest, "unsupported lang; use ru, id, pt, or fr")
|
||||
return
|
||||
}
|
||||
if body.From < 1 || body.To < body.From {
|
||||
jsonError(w, http.StatusBadRequest, "from must be >= 1 and to must be >= from")
|
||||
return
|
||||
}
|
||||
if body.To-body.From > 999 {
|
||||
jsonError(w, http.StatusBadRequest, "range too large; max 1000 chapters per request")
|
||||
return
|
||||
}
|
||||
|
||||
var taskIDs []string
|
||||
for n := body.From; n <= body.To; n++ {
|
||||
id, err := s.deps.Producer.CreateTranslationTask(r.Context(), body.Slug, n, body.Lang)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAdminTranslationBulk: CreateTranslationTask failed",
|
||||
"slug", body.Slug, "chapter", n, "lang", body.Lang, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError,
|
||||
fmt.Sprintf("failed to create task for chapter %d: %s", n, err))
|
||||
return
|
||||
}
|
||||
taskIDs = append(taskIDs, id)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"enqueued": len(taskIDs),
|
||||
"task_ids": taskIDs,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Voices ─────────────────────────────────────────────────────────────────────
|
||||
// Returns {"voices": [...]} — merged list from Kokoro and pocket-tts.
|
||||
func (s *Server) handleVoices(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 0, map[string]any{"voices": s.voices(r.Context())})
|
||||
|
||||
@@ -47,6 +47,8 @@ type Dependencies struct {
|
||||
RankingStore bookstore.RankingStore
|
||||
// AudioStore checks audio object existence and computes MinIO keys.
|
||||
AudioStore bookstore.AudioStore
|
||||
// TranslationStore checks translation existence and reads/writes translated markdown.
|
||||
TranslationStore bookstore.TranslationStore
|
||||
// PresignStore generates short-lived MinIO URLs.
|
||||
PresignStore bookstore.PresignStore
|
||||
// ProgressStore reads/writes per-session reading progress.
|
||||
@@ -160,6 +162,15 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
mux.HandleFunc("GET /api/audio/status/{slug}/{n}", s.handleAudioStatus)
|
||||
mux.HandleFunc("GET /api/audio-proxy/{slug}/{n}", s.handleAudioProxy)
|
||||
|
||||
// Translation task creation (backend creates task; runner executes via LibreTranslate)
|
||||
mux.HandleFunc("POST /api/translation/{slug}/{n}", s.handleTranslationGenerate)
|
||||
mux.HandleFunc("GET /api/translation/status/{slug}/{n}", s.handleTranslationStatus)
|
||||
mux.HandleFunc("GET /api/translation/{slug}/{n}", s.handleTranslationRead)
|
||||
|
||||
// Admin translation endpoints
|
||||
mux.HandleFunc("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
|
||||
mux.HandleFunc("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
|
||||
|
||||
// Voices list
|
||||
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||
|
||||
|
||||
@@ -141,3 +141,19 @@ type CoverStore interface {
|
||||
// CoverExists returns true when a cover image is stored for slug.
|
||||
CoverExists(ctx context.Context, slug string) bool
|
||||
}
|
||||
|
||||
// TranslationStore covers machine-translated chapter storage in MinIO.
|
||||
// The runner writes translations; the backend reads them.
|
||||
type TranslationStore interface {
|
||||
// TranslationObjectKey returns the MinIO object key for a cached translation.
|
||||
TranslationObjectKey(lang, slug string, n int) string
|
||||
|
||||
// TranslationExists returns true when the translation object is present in MinIO.
|
||||
TranslationExists(ctx context.Context, key string) bool
|
||||
|
||||
// PutTranslation stores raw translated markdown under the given MinIO object key.
|
||||
PutTranslation(ctx context.Context, key string, data []byte) error
|
||||
|
||||
// GetTranslation retrieves translated markdown from MinIO.
|
||||
GetTranslation(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ type MinIO struct {
|
||||
BucketAvatars string
|
||||
// BucketBrowse is the bucket that holds cached browse page snapshots (JSON).
|
||||
BucketBrowse string
|
||||
// BucketTranslations is the bucket that holds machine-translated chapter markdown.
|
||||
BucketTranslations string
|
||||
}
|
||||
|
||||
// Kokoro holds connection settings for the Kokoro-FastAPI TTS service.
|
||||
@@ -64,6 +66,16 @@ type PocketTTS struct {
|
||||
URL string
|
||||
}
|
||||
|
||||
// LibreTranslate holds connection settings for a self-hosted LibreTranslate instance.
|
||||
type LibreTranslate struct {
|
||||
// URL is the base URL of the LibreTranslate instance, e.g. https://translate.libnovel.cc
|
||||
// An empty string disables machine translation entirely.
|
||||
URL string
|
||||
// APIKey is the optional API key for the LibreTranslate instance.
|
||||
// Leave empty if the instance runs without authentication.
|
||||
APIKey string
|
||||
}
|
||||
|
||||
// HTTP holds settings for the HTTP server (backend only).
|
||||
type HTTP struct {
|
||||
// Addr is the listen address, e.g. ":8080"
|
||||
@@ -107,6 +119,8 @@ type Runner struct {
|
||||
MaxConcurrentScrape int
|
||||
// MaxConcurrentAudio limits simultaneous audio-generation goroutines.
|
||||
MaxConcurrentAudio int
|
||||
// MaxConcurrentTranslation limits simultaneous translation goroutines.
|
||||
MaxConcurrentTranslation int
|
||||
// WorkerID is a unique identifier for this runner instance.
|
||||
// Defaults to the system hostname.
|
||||
WorkerID string
|
||||
@@ -126,19 +140,25 @@ type Runner struct {
|
||||
// is already indexed and a 24h walk would be wasteful.
|
||||
// Controlled by RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true.
|
||||
SkipInitialCatalogueRefresh bool
|
||||
// CatalogueRequestDelay is the base delay inserted between per-book metadata
|
||||
// requests during a catalogue refresh. A random jitter of up to 50% is added
|
||||
// on top. Defaults to 2s. Increase to reduce 429 pressure on novelfire.net.
|
||||
// Controlled by RUNNER_CATALOGUE_REQUEST_DELAY (e.g. "3s", "500ms").
|
||||
CatalogueRequestDelay time.Duration
|
||||
}
|
||||
|
||||
// Config is the top-level configuration struct consumed by both binaries.
|
||||
type Config struct {
|
||||
PocketBase PocketBase
|
||||
MinIO MinIO
|
||||
Kokoro Kokoro
|
||||
PocketTTS PocketTTS
|
||||
HTTP HTTP
|
||||
Runner Runner
|
||||
Meilisearch Meilisearch
|
||||
Valkey Valkey
|
||||
Redis Redis
|
||||
PocketBase PocketBase
|
||||
MinIO MinIO
|
||||
Kokoro Kokoro
|
||||
PocketTTS PocketTTS
|
||||
LibreTranslate LibreTranslate
|
||||
HTTP HTTP
|
||||
Runner Runner
|
||||
Meilisearch Meilisearch
|
||||
Valkey Valkey
|
||||
Redis Redis
|
||||
// LogLevel is one of "debug", "info", "warn", "error".
|
||||
LogLevel string
|
||||
}
|
||||
@@ -161,16 +181,17 @@ func Load() Config {
|
||||
},
|
||||
|
||||
MinIO: MinIO{
|
||||
Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"),
|
||||
PublicEndpoint: envOr("MINIO_PUBLIC_ENDPOINT", ""),
|
||||
AccessKey: envOr("MINIO_ACCESS_KEY", "admin"),
|
||||
SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"),
|
||||
UseSSL: envBool("MINIO_USE_SSL", false),
|
||||
PublicUseSSL: envBool("MINIO_PUBLIC_USE_SSL", true),
|
||||
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "chapters"),
|
||||
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "audio"),
|
||||
BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "avatars"),
|
||||
BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "catalogue"),
|
||||
Endpoint: envOr("MINIO_ENDPOINT", "localhost:9000"),
|
||||
PublicEndpoint: envOr("MINIO_PUBLIC_ENDPOINT", ""),
|
||||
AccessKey: envOr("MINIO_ACCESS_KEY", "admin"),
|
||||
SecretKey: envOr("MINIO_SECRET_KEY", "changeme123"),
|
||||
UseSSL: envBool("MINIO_USE_SSL", false),
|
||||
PublicUseSSL: envBool("MINIO_PUBLIC_USE_SSL", true),
|
||||
BucketChapters: envOr("MINIO_BUCKET_CHAPTERS", "chapters"),
|
||||
BucketAudio: envOr("MINIO_BUCKET_AUDIO", "audio"),
|
||||
BucketAvatars: envOr("MINIO_BUCKET_AVATARS", "avatars"),
|
||||
BucketBrowse: envOr("MINIO_BUCKET_BROWSE", "catalogue"),
|
||||
BucketTranslations: envOr("MINIO_BUCKET_TRANSLATIONS", "translations"),
|
||||
},
|
||||
|
||||
Kokoro: Kokoro{
|
||||
@@ -190,12 +211,14 @@ func Load() Config {
|
||||
PollInterval: envDuration("RUNNER_POLL_INTERVAL", 30*time.Second),
|
||||
MaxConcurrentScrape: envInt("RUNNER_MAX_CONCURRENT_SCRAPE", 1),
|
||||
MaxConcurrentAudio: envInt("RUNNER_MAX_CONCURRENT_AUDIO", 1),
|
||||
MaxConcurrentTranslation: envInt("RUNNER_MAX_CONCURRENT_TRANSLATION", 1),
|
||||
WorkerID: envOr("RUNNER_WORKER_ID", workerID),
|
||||
Workers: envInt("RUNNER_WORKERS", 0), // 0 → runtime.NumCPU()
|
||||
Timeout: envDuration("RUNNER_TIMEOUT", 90*time.Second),
|
||||
MetricsAddr: envOr("RUNNER_METRICS_ADDR", ":9091"),
|
||||
CatalogueRefreshInterval: envDuration("RUNNER_CATALOGUE_REFRESH_INTERVAL", 0),
|
||||
SkipInitialCatalogueRefresh: envBool("RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH", false),
|
||||
CatalogueRequestDelay: envDuration("RUNNER_CATALOGUE_REQUEST_DELAY", 2*time.Second),
|
||||
},
|
||||
|
||||
Meilisearch: Meilisearch{
|
||||
|
||||
@@ -149,3 +149,23 @@ type AudioResult struct {
|
||||
ObjectKey string `json:"object_key,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
// TranslationTask represents a machine-translation job stored in PocketBase.
|
||||
type TranslationTask struct {
|
||||
ID string `json:"id"`
|
||||
CacheKey string `json:"cache_key"` // "{slug}/{chapter}/{lang}"
|
||||
Slug string `json:"slug"`
|
||||
Chapter int `json:"chapter"`
|
||||
Lang string `json:"lang"`
|
||||
WorkerID string `json:"worker_id,omitempty"`
|
||||
Status TaskStatus `json:"status"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
Started time.Time `json:"started"`
|
||||
Finished time.Time `json:"finished,omitempty"`
|
||||
}
|
||||
|
||||
// TranslationResult is the outcome reported by the runner after finishing a TranslationTask.
|
||||
type TranslationResult struct {
|
||||
ObjectKey string `json:"object_key,omitempty"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
181
backend/internal/libretranslate/client.go
Normal file
181
backend/internal/libretranslate/client.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Package libretranslate provides an HTTP client for a self-hosted
|
||||
// LibreTranslate instance. It handles text chunking, concurrent translation,
|
||||
// and reassembly so callers can pass arbitrarily long markdown strings.
|
||||
package libretranslate
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxChunkBytes is the target maximum size of each chunk sent to
|
||||
// LibreTranslate. LibreTranslate's default limit is 5000 characters;
|
||||
// we stay comfortably below that.
|
||||
maxChunkBytes = 4500
|
||||
// concurrency is the number of simultaneous translation requests per chapter.
|
||||
concurrency = 3
|
||||
)
|
||||
|
||||
// Client translates text via LibreTranslate.
|
||||
// A nil Client is valid — all calls return the original text unchanged.
|
||||
type Client interface {
|
||||
// Translate translates text from sourceLang to targetLang.
|
||||
// text is a raw markdown string. The returned string is the translated
|
||||
// markdown, reassembled in original paragraph order.
|
||||
Translate(ctx context.Context, text, sourceLang, targetLang string) (string, error)
|
||||
}
|
||||
|
||||
// New returns a Client for the given LibreTranslate URL.
|
||||
// Returns nil when url is empty, which disables translation.
|
||||
func New(url, apiKey string) Client {
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
return &httpClient{
|
||||
url: strings.TrimRight(url, "/"),
|
||||
apiKey: apiKey,
|
||||
http: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type httpClient struct {
|
||||
url string
|
||||
apiKey string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// Translate splits text into paragraph chunks, translates them concurrently
|
||||
// (up to concurrency goroutines), and reassembles in order.
|
||||
func (c *httpClient) Translate(ctx context.Context, text, sourceLang, targetLang string) (string, error) {
|
||||
paragraphs := splitParagraphs(text)
|
||||
if len(paragraphs) == 0 {
|
||||
return text, nil
|
||||
}
|
||||
chunks := binChunks(paragraphs, maxChunkBytes)
|
||||
|
||||
translated := make([]string, len(chunks))
|
||||
errs := make([]error, len(chunks))
|
||||
|
||||
sem := make(chan struct{}, concurrency)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i, chunk := range chunks {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(idx int, chunkText string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
result, err := c.translateChunk(ctx, chunkText, sourceLang, targetLang)
|
||||
translated[idx] = result
|
||||
errs[idx] = err
|
||||
}(i, chunk)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(translated, "\n\n"), nil
|
||||
}
|
||||
|
||||
// translateChunk sends a single POST /translate request.
|
||||
func (c *httpClient) translateChunk(ctx context.Context, text, sourceLang, targetLang string) (string, error) {
|
||||
reqBody := map[string]string{
|
||||
"q": text,
|
||||
"source": sourceLang,
|
||||
"target": targetLang,
|
||||
"format": "html",
|
||||
}
|
||||
if c.apiKey != "" {
|
||||
reqBody["api_key"] = c.apiKey
|
||||
}
|
||||
|
||||
b, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("libretranslate: marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url+"/translate", bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("libretranslate: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("libretranslate: request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
var errBody struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&errBody)
|
||||
return "", fmt.Errorf("libretranslate: status %d: %s", resp.StatusCode, errBody.Error)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
TranslatedText string `json:"translatedText"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("libretranslate: decode response: %w", err)
|
||||
}
|
||||
return result.TranslatedText, nil
|
||||
}
|
||||
|
||||
// splitParagraphs splits markdown text on blank lines, preserving non-empty paragraphs.
|
||||
func splitParagraphs(text string) []string {
|
||||
// Normalise line endings.
|
||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||
// Split on double newlines (blank lines between paragraphs).
|
||||
parts := strings.Split(text, "\n\n")
|
||||
var paragraphs []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
paragraphs = append(paragraphs, p)
|
||||
}
|
||||
}
|
||||
return paragraphs
|
||||
}
|
||||
|
||||
// binChunks groups paragraphs into chunks each at most maxBytes in length.
|
||||
// Each chunk is a single string with paragraphs joined by "\n\n".
|
||||
func binChunks(paragraphs []string, maxBytes int) []string {
|
||||
var chunks []string
|
||||
var current strings.Builder
|
||||
|
||||
for _, p := range paragraphs {
|
||||
needed := len(p)
|
||||
if current.Len() > 0 {
|
||||
needed += 2 // for the "\n\n" separator
|
||||
}
|
||||
|
||||
if current.Len()+needed > maxBytes && current.Len() > 0 {
|
||||
// Flush current chunk.
|
||||
chunks = append(chunks, current.String())
|
||||
current.Reset()
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
current.WriteString("\n\n")
|
||||
}
|
||||
current.WriteString(p)
|
||||
}
|
||||
|
||||
if current.Len() > 0 {
|
||||
chunks = append(chunks, current.String())
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
@@ -55,6 +56,9 @@ func (s *Scraper) SourceName() string { return "novelfire.net" }
|
||||
// ── CatalogueProvider ─────────────────────────────────────────────────────────
|
||||
|
||||
// ScrapeCatalogue streams all CatalogueEntry values across all catalogue pages.
|
||||
// Each page fetch uses retryGet with 429-aware exponential backoff.
|
||||
// A small inter-page delay (cataloguePageDelay) is inserted between requests to
|
||||
// avoid hammering the server when paging through hundreds of catalogue pages.
|
||||
func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueEntry, <-chan error) {
|
||||
entries := make(chan domain.CatalogueEntry, 64)
|
||||
errs := make(chan error, 16)
|
||||
@@ -73,8 +77,18 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueE
|
||||
default:
|
||||
}
|
||||
|
||||
// Polite inter-page delay — skipped on the very first page.
|
||||
if page > 1 {
|
||||
jitter := time.Duration(500+rand.Intn(1000)) * time.Millisecond
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(jitter):
|
||||
}
|
||||
}
|
||||
|
||||
s.log.Info("scraping catalogue page", "page", page, "url", pageURL)
|
||||
raw, err := s.client.GetContent(ctx, pageURL)
|
||||
raw, err := retryGet(ctx, s.log, s.client, pageURL, 9, 10*time.Second)
|
||||
if err != nil {
|
||||
errs <- fmt.Errorf("catalogue page %d: %w", page, err)
|
||||
return
|
||||
@@ -139,10 +153,11 @@ func (s *Scraper) ScrapeCatalogue(ctx context.Context) (<-chan domain.CatalogueE
|
||||
// ── MetadataProvider ──────────────────────────────────────────────────────────
|
||||
|
||||
// ScrapeMetadata fetches and parses book metadata from the book's landing page.
|
||||
// Uses retryGet with 429-aware exponential backoff (up to 9 attempts).
|
||||
func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (domain.BookMeta, error) {
|
||||
s.log.Debug("metadata fetch starting", "url", bookURL)
|
||||
|
||||
raw, err := s.client.GetContent(ctx, bookURL)
|
||||
raw, err := retryGet(ctx, s.log, s.client, bookURL, 9, 10*time.Second)
|
||||
if err != nil {
|
||||
return domain.BookMeta{}, fmt.Errorf("metadata fetch %s: %w", bookURL, err)
|
||||
}
|
||||
@@ -163,12 +178,26 @@ func (s *Scraper) ScrapeMetadata(ctx context.Context, bookURL string) (domain.Bo
|
||||
}
|
||||
}
|
||||
|
||||
status := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "span", Class: "status"})
|
||||
// Status: novelfire renders <strong class="ongoing">Ongoing</strong> (or
|
||||
// "completed", "hiatus") inside the .header-stats block. We take the text
|
||||
// content and lowercase it so the index value is always canonical lowercase.
|
||||
var status string
|
||||
for _, cls := range []string{"ongoing", "completed", "hiatus"} {
|
||||
if v := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "strong", Class: cls}); v != "" {
|
||||
status = strings.ToLower(strings.TrimSpace(v))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
genresNode := htmlutil.FindFirst(root, scraper.Selector{Tag: "div", Class: "genres"})
|
||||
// Genres: novelfire renders <div class="categories"><ul><li><a class="property-item">Genre</a>
|
||||
// Each <a class="property-item"> is one genre tag. Lowercase for index consistency.
|
||||
var genres []string
|
||||
if genresNode != nil {
|
||||
genres = htmlutil.ExtractAll(genresNode, scraper.Selector{Tag: "a", Multiple: true})
|
||||
if categoriesNode := htmlutil.FindFirst(root, scraper.Selector{Tag: "div", Class: "categories"}); categoriesNode != nil {
|
||||
for _, v := range htmlutil.ExtractAll(categoriesNode, scraper.Selector{Tag: "a", Class: "property-item", Multiple: true}) {
|
||||
if v != "" {
|
||||
genres = append(genres, strings.ToLower(strings.TrimSpace(v)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summary := htmlutil.ExtractFirst(root, scraper.Selector{Tag: "div", Class: "summary"})
|
||||
|
||||
@@ -2,6 +2,7 @@ package novelfire
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -100,6 +101,56 @@ func TestRetryGet_EventualSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseMetadataSelectors verifies that the status and genres selectors
|
||||
// match the current novelfire.net HTML structure.
|
||||
func TestParseMetadataSelectors(t *testing.T) {
|
||||
// Minimal HTML reproducing the relevant novelfire.net book page structure.
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<h1 class="novel-title">Shadow Slave</h1>
|
||||
<span class="author">Guiltythree</span>
|
||||
<figure class="cover"><img src="https://cdn.example.com/cover.jpg"></figure>
|
||||
<div class="header-stats">
|
||||
<span><strong>123</strong><small>Chapters</small></span>
|
||||
<span> <strong class="ongoing">Ongoing</strong> <small>Status</small></span>
|
||||
</div>
|
||||
<div class="categories">
|
||||
<h4>Genres</h4>
|
||||
<ul>
|
||||
<li><a href="/genre-fantasy/..." class="property-item">Fantasy</a></li>
|
||||
<li><a href="/genre-action/..." class="property-item">Action</a></li>
|
||||
<li><a href="/genre-adventure/..." class="property-item">Adventure</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<span class="chapter-count">123 Chapters</span>
|
||||
</body></html>`
|
||||
|
||||
stub := newStubClient()
|
||||
stub.setFn("https://novelfire.net/book/shadow-slave", func() (string, error) {
|
||||
return html, nil
|
||||
})
|
||||
|
||||
s := &Scraper{client: stub, log: slog.Default()}
|
||||
meta, err := s.ScrapeMetadata(t.Context(), "https://novelfire.net/book/shadow-slave")
|
||||
if err != nil {
|
||||
t.Fatalf("ScrapeMetadata: %v", err)
|
||||
}
|
||||
|
||||
if meta.Status != "ongoing" {
|
||||
t.Errorf("status = %q, want %q", meta.Status, "ongoing")
|
||||
}
|
||||
|
||||
wantGenres := []string{"fantasy", "action", "adventure"}
|
||||
if len(meta.Genres) != len(wantGenres) {
|
||||
t.Fatalf("genres = %v, want %v", meta.Genres, wantGenres)
|
||||
}
|
||||
for i, g := range meta.Genres {
|
||||
if g != wantGenres[i] {
|
||||
t.Errorf("genres[%d] = %q, want %q", i, g, wantGenres[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── minimal stub client for tests ─────────────────────────────────────────────
|
||||
|
||||
type stubClient struct {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
@@ -72,6 +74,44 @@ func (r *Runner) runAsynq(ctx context.Context) error {
|
||||
|
||||
r.deps.Log.Info("runner: asynq mode active", "redis_addr", r.cfg.RedisAddr)
|
||||
|
||||
// ── Heartbeat goroutine ──────────────────────────────────────────────
|
||||
// Write /tmp/runner.alive every 30s so Docker healthcheck passes in asynq mode.
|
||||
// This mirrors the heartbeat file behavior from the poll() loop.
|
||||
go func() {
|
||||
heartbeatTick := time.NewTicker(r.cfg.StaleTaskThreshold)
|
||||
defer heartbeatTick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-heartbeatTick.C:
|
||||
if f, err := os.Create("/tmp/runner.alive"); err != nil {
|
||||
r.deps.Log.Warn("runner: could not write heartbeat file", "err", err)
|
||||
} else {
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// ── Translation polling goroutine ────────────────────────────────────
|
||||
// Translation tasks live in PocketBase (not Redis), so we need a separate
|
||||
// poll loop to claim and dispatch them. This runs alongside the Asynq server.
|
||||
translationSem := make(chan struct{}, r.cfg.MaxConcurrentTranslation)
|
||||
var translationWg sync.WaitGroup
|
||||
go func() {
|
||||
tick := time.NewTicker(r.cfg.PollInterval)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
r.pollTranslationTasks(ctx, translationSem, &translationWg)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Run catalogue refresh ticker in the background.
|
||||
go func() {
|
||||
for {
|
||||
@@ -93,6 +133,9 @@ func (r *Runner) runAsynq(ctx context.Context) error {
|
||||
<-ctx.Done()
|
||||
r.deps.Log.Info("runner: context cancelled, shutting down asynq server")
|
||||
srv.Shutdown()
|
||||
|
||||
// Wait for translation tasks to complete.
|
||||
translationWg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -147,3 +190,47 @@ func (r *Runner) handleAudioTask(ctx context.Context, t *asynq.Task) error {
|
||||
r.runAudioTask(ctx, task)
|
||||
return nil
|
||||
}
|
||||
|
||||
// pollTranslationTasks claims all available translation tasks from PocketBase
|
||||
// and dispatches them to goroutines. Translation tasks don't go through Redis/Asynq
|
||||
// because they're stored in PocketBase, so we need this separate poll loop.
|
||||
func (r *Runner) pollTranslationTasks(ctx context.Context, translationSem chan struct{}, wg *sync.WaitGroup) {
|
||||
// Reap orphaned tasks (same logic as poll() in runner.go).
|
||||
if n, err := r.deps.Consumer.ReapStaleTasks(ctx, r.cfg.StaleTaskThreshold); err != nil {
|
||||
r.deps.Log.Warn("runner: reap stale translation tasks failed", "err", err)
|
||||
} else if n > 0 {
|
||||
r.deps.Log.Info("runner: reaped stale translation tasks", "count", n)
|
||||
}
|
||||
|
||||
translationLoop:
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case translationSem <- struct{}{}:
|
||||
// Slot acquired — proceed to claim a task.
|
||||
default:
|
||||
// All slots busy; leave remaining pending tasks for next tick.
|
||||
break translationLoop
|
||||
}
|
||||
task, ok, err := r.deps.Consumer.ClaimNextTranslationTask(ctx, r.cfg.WorkerID)
|
||||
if err != nil {
|
||||
<-translationSem
|
||||
r.deps.Log.Error("runner: ClaimNextTranslationTask failed", "err", err)
|
||||
break
|
||||
}
|
||||
if !ok {
|
||||
<-translationSem
|
||||
break
|
||||
}
|
||||
r.tasksRunning.Add(1)
|
||||
wg.Add(1)
|
||||
go func(t domain.TranslationTask) {
|
||||
defer wg.Done()
|
||||
defer func() { <-translationSem }()
|
||||
defer r.tasksRunning.Add(-1)
|
||||
r.runTranslationTask(ctx, t)
|
||||
}(task)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,17 +6,20 @@ package runner
|
||||
//
|
||||
// Design:
|
||||
// - Runs on its own ticker (CatalogueRefreshInterval, default 24h) inside Run().
|
||||
// - Also fires once on startup.
|
||||
// - ScrapeCatalogue streams CatalogueEntry values over a channel — we iterate
|
||||
// and call ScrapeMetadata for each entry.
|
||||
// - Per-request random jitter (1–3s) prevents hammering novelfire.net.
|
||||
// - Cover images are fetched from the URL embedded in BookMeta.Cover and
|
||||
// stored in MinIO (browse bucket, key: covers/{slug}.jpg).
|
||||
// - WriteMetadata + UpsertBook are called for every successfully scraped book.
|
||||
// - Errors for individual books are logged and skipped; the loop continues.
|
||||
// - The cover URL stored in BookMeta.Cover is rewritten to the internal proxy
|
||||
// path (/api/cover/novelfire.net/{slug}) so the UI always fetches via the
|
||||
// backend, which will serve from MinIO.
|
||||
// - Also fires once on startup (unless SkipInitialCatalogueRefresh is set).
|
||||
// - ScrapeCatalogue streams CatalogueEntry values over a channel — already has
|
||||
// its own inter-page jitter + retryGet (see scraper.go).
|
||||
// - Per-book: only metadata is scraped here (not chapters). Chapters are scraped
|
||||
// on-demand when a user opens a book or via an explicit scrape task.
|
||||
// - Between each metadata request a configurable base delay plus up to 50%
|
||||
// random jitter is applied (CatalogueRequestDelay, default 2s). This keeps
|
||||
// the request rate well below novelfire.net's rate limit even for ~15k books.
|
||||
// - ScrapeMetadata itself uses retryGet with 429-aware exponential backoff
|
||||
// (up to 9 attempts), so transient rate limits are handled gracefully.
|
||||
// - Cover images are fetched and stored in MinIO on first sight; subsequent
|
||||
// refreshes skip covers that already exist (CoverExists check).
|
||||
// - Books already present in Meilisearch are skipped entirely (fast path).
|
||||
// - Errors for individual books are logged and skipped; the loop never aborts.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -29,7 +32,7 @@ import (
|
||||
|
||||
// runCatalogueRefresh performs one full catalogue walk: scrapes metadata for
|
||||
// every book on novelfire.net, downloads covers to MinIO, and upserts to
|
||||
// Meilisearch. Errors for individual books are logged and skipped.
|
||||
// Meilisearch. Individual book failures are logged and skipped.
|
||||
func (r *Runner) runCatalogueRefresh(ctx context.Context) {
|
||||
if r.deps.Novel == nil {
|
||||
r.deps.Log.Warn("runner: catalogue refresh skipped — Novel scraper not configured")
|
||||
@@ -40,8 +43,9 @@ func (r *Runner) runCatalogueRefresh(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
delay := r.cfg.CatalogueRequestDelay
|
||||
log := r.deps.Log.With("op", "catalogue_refresh")
|
||||
log.Info("runner: catalogue refresh starting")
|
||||
log.Info("runner: catalogue refresh starting", "request_delay", delay)
|
||||
|
||||
entries, errCh := r.deps.Novel.ScrapeCatalogue(ctx)
|
||||
|
||||
@@ -51,26 +55,26 @@ func (r *Runner) runCatalogueRefresh(ctx context.Context) {
|
||||
break
|
||||
}
|
||||
|
||||
// Skip books already present in Meilisearch — they were indexed on a
|
||||
// previous run. Re-indexing only happens when a scrape task is
|
||||
// explicitly enqueued (e.g. via the admin UI or API).
|
||||
// Fast path: skip books already indexed in Meilisearch.
|
||||
if r.deps.SearchIndex.BookExists(ctx, entry.Slug) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Random jitter between books to avoid rate-limiting.
|
||||
jitter := time.Duration(1000+rand.Intn(2000)) * time.Millisecond
|
||||
// Polite delay between metadata requests: base + up to 50% jitter.
|
||||
// This applies before every fetch so we never fire bursts.
|
||||
jitter := time.Duration(rand.Int63n(int64(delay / 2)))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break
|
||||
case <-time.After(jitter):
|
||||
case <-time.After(delay + jitter):
|
||||
}
|
||||
|
||||
// ScrapeMetadata internally retries on 429 with exponential back-off.
|
||||
meta, err := r.deps.Novel.ScrapeMetadata(ctx, entry.URL)
|
||||
if err != nil {
|
||||
log.Warn("runner: catalogue refresh: metadata scrape failed",
|
||||
"url", entry.URL, "err", err)
|
||||
log.Warn("runner: catalogue refresh: metadata scrape failed — skipping book",
|
||||
"slug", entry.Slug, "url", entry.URL, "err", err)
|
||||
errCount++
|
||||
continue
|
||||
}
|
||||
@@ -81,35 +85,32 @@ func (r *Runner) runCatalogueRefresh(ctx context.Context) {
|
||||
|
||||
// Persist to PocketBase.
|
||||
if err := r.deps.BookWriter.WriteMetadata(ctx, meta); err != nil {
|
||||
log.Warn("runner: catalogue refresh: WriteMetadata failed",
|
||||
log.Warn("runner: catalogue refresh: WriteMetadata failed — skipping book",
|
||||
"slug", meta.Slug, "err", err)
|
||||
errCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Index in Meilisearch.
|
||||
// Index in Meilisearch (non-fatal).
|
||||
if err := r.deps.SearchIndex.UpsertBook(ctx, meta); err != nil {
|
||||
log.Warn("runner: catalogue refresh: UpsertBook failed",
|
||||
"slug", meta.Slug, "err", err)
|
||||
// non-fatal — continue
|
||||
}
|
||||
|
||||
// Download and store cover image in MinIO if we have a cover URL
|
||||
// and a CoverStore is wired in.
|
||||
// Download cover to MinIO if not already cached (non-fatal).
|
||||
if r.deps.CoverStore != nil && originalCover != "" {
|
||||
if !r.deps.CoverStore.CoverExists(ctx, meta.Slug) {
|
||||
if err := r.downloadCover(ctx, meta.Slug, originalCover); err != nil {
|
||||
log.Warn("runner: catalogue refresh: cover download failed",
|
||||
"slug", meta.Slug, "url", originalCover, "err", err)
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ok++
|
||||
if ok%100 == 0 {
|
||||
if ok%50 == 0 {
|
||||
log.Info("runner: catalogue refresh progress",
|
||||
"scraped", ok, "errors", errCount)
|
||||
"scraped", ok, "skipped", skipped, "errors", errCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/libnovel/backend/internal/bookstore"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/kokoro"
|
||||
"github.com/libnovel/backend/internal/libretranslate"
|
||||
"github.com/libnovel/backend/internal/meili"
|
||||
"github.com/libnovel/backend/internal/orchestrator"
|
||||
"github.com/libnovel/backend/internal/pockettts"
|
||||
@@ -48,6 +49,8 @@ type Config struct {
|
||||
MaxConcurrentScrape int
|
||||
// MaxConcurrentAudio limits simultaneous audio-generation goroutines.
|
||||
MaxConcurrentAudio int
|
||||
// MaxConcurrentTranslation limits simultaneous translation goroutines.
|
||||
MaxConcurrentTranslation int
|
||||
// OrchestratorWorkers is the chapter-scraping parallelism inside each book run.
|
||||
OrchestratorWorkers int
|
||||
// HeartbeatInterval is how often active tasks PATCH their heartbeat_at
|
||||
@@ -62,6 +65,10 @@ type Config struct {
|
||||
// scrapes per-book metadata, downloads covers, and re-indexes everything in
|
||||
// Meilisearch. Defaults to 24h (expensive — full catalogue walk).
|
||||
CatalogueRefreshInterval time.Duration
|
||||
// CatalogueRequestDelay is the base inter-request pause during a catalogue
|
||||
// refresh metadata walk. Jitter of up to 50% is added on top.
|
||||
// Defaults to 2s. Set via RUNNER_CATALOGUE_REQUEST_DELAY.
|
||||
CatalogueRequestDelay time.Duration
|
||||
// SkipInitialCatalogueRefresh suppresses the immediate catalogue walk that
|
||||
// otherwise fires at startup. The periodic ticker (CatalogueRefreshInterval)
|
||||
// still fires normally. Set RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true for
|
||||
@@ -91,6 +98,8 @@ type Dependencies struct {
|
||||
BookReader bookstore.BookReader
|
||||
// AudioStore persists generated audio and checks key existence.
|
||||
AudioStore bookstore.AudioStore
|
||||
// TranslationStore persists translated markdown and checks key existence.
|
||||
TranslationStore bookstore.TranslationStore
|
||||
// CoverStore stores book cover images in MinIO.
|
||||
CoverStore bookstore.CoverStore
|
||||
// SearchIndex indexes books in Meilisearch after scraping.
|
||||
@@ -103,6 +112,9 @@ type Dependencies struct {
|
||||
// PocketTTS is the pocket-tts client (CPU, kyutai voices: alba, marius, etc.).
|
||||
// If nil, pocket-tts voice tasks will fail with a clear error.
|
||||
PocketTTS pockettts.Client
|
||||
// LibreTranslate is the machine translation client.
|
||||
// If nil, translation tasks will fail with a clear error.
|
||||
LibreTranslate libretranslate.Client
|
||||
// Log is the structured logger.
|
||||
Log *slog.Logger
|
||||
}
|
||||
@@ -133,6 +145,9 @@ func New(cfg Config, deps Dependencies) *Runner {
|
||||
if cfg.MaxConcurrentAudio <= 0 {
|
||||
cfg.MaxConcurrentAudio = 1
|
||||
}
|
||||
if cfg.MaxConcurrentTranslation <= 0 {
|
||||
cfg.MaxConcurrentTranslation = 1
|
||||
}
|
||||
if cfg.WorkerID == "" {
|
||||
cfg.WorkerID = "runner"
|
||||
}
|
||||
@@ -145,6 +160,9 @@ func New(cfg Config, deps Dependencies) *Runner {
|
||||
if cfg.CatalogueRefreshInterval <= 0 {
|
||||
cfg.CatalogueRefreshInterval = 24 * time.Hour
|
||||
}
|
||||
if cfg.CatalogueRequestDelay <= 0 {
|
||||
cfg.CatalogueRequestDelay = 2 * time.Second
|
||||
}
|
||||
if cfg.MetricsAddr == "" {
|
||||
cfg.MetricsAddr = ":9091"
|
||||
}
|
||||
@@ -168,6 +186,7 @@ func (r *Runner) Run(ctx context.Context) error {
|
||||
"mode", r.mode(),
|
||||
"max_scrape", r.cfg.MaxConcurrentScrape,
|
||||
"max_audio", r.cfg.MaxConcurrentAudio,
|
||||
"max_translation", r.cfg.MaxConcurrentTranslation,
|
||||
"catalogue_refresh_interval", r.cfg.CatalogueRefreshInterval,
|
||||
"metrics_addr", r.cfg.MetricsAddr,
|
||||
)
|
||||
@@ -201,6 +220,7 @@ func (r *Runner) mode() string {
|
||||
func (r *Runner) runPoll(ctx context.Context) error {
|
||||
scrapeSem := make(chan struct{}, r.cfg.MaxConcurrentScrape)
|
||||
audioSem := make(chan struct{}, r.cfg.MaxConcurrentAudio)
|
||||
translationSem := make(chan struct{}, r.cfg.MaxConcurrentTranslation)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
tick := time.NewTicker(r.cfg.PollInterval)
|
||||
@@ -220,7 +240,7 @@ func (r *Runner) runPoll(ctx context.Context) error {
|
||||
|
||||
// Run one poll immediately on startup, then on each tick.
|
||||
for {
|
||||
r.poll(ctx, scrapeSem, audioSem, &wg)
|
||||
r.poll(ctx, scrapeSem, audioSem, translationSem, &wg)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -245,7 +265,7 @@ func (r *Runner) runPoll(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// poll claims all available pending tasks and dispatches them to goroutines.
|
||||
func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem chan struct{}, wg *sync.WaitGroup) {
|
||||
func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem, translationSem chan struct{}, wg *sync.WaitGroup) {
|
||||
// ── Heartbeat file ────────────────────────────────────────────────────
|
||||
// Touch /tmp/runner.alive so the Docker health check can confirm the
|
||||
// runner is actively polling. Failure is non-fatal — just log it.
|
||||
@@ -328,6 +348,39 @@ audioLoop:
|
||||
r.runAudioTask(ctx, t)
|
||||
}(task)
|
||||
}
|
||||
|
||||
// ── Translation tasks ─────────────────────────────────────────────────
|
||||
translationLoop:
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case translationSem <- struct{}{}:
|
||||
// Slot acquired — proceed to claim a task.
|
||||
default:
|
||||
// All slots busy; leave remaining pending tasks for next tick.
|
||||
break translationLoop
|
||||
}
|
||||
task, ok, err := r.deps.Consumer.ClaimNextTranslationTask(ctx, r.cfg.WorkerID)
|
||||
if err != nil {
|
||||
<-translationSem
|
||||
r.deps.Log.Error("runner: ClaimNextTranslationTask failed", "err", err)
|
||||
break
|
||||
}
|
||||
if !ok {
|
||||
<-translationSem
|
||||
break
|
||||
}
|
||||
r.tasksRunning.Add(1)
|
||||
wg.Add(1)
|
||||
go func(t domain.TranslationTask) {
|
||||
defer wg.Done()
|
||||
defer func() { <-translationSem }()
|
||||
defer r.tasksRunning.Add(-1)
|
||||
r.runTranslationTask(ctx, t)
|
||||
}(task)
|
||||
}
|
||||
}
|
||||
|
||||
// newOrchestrator builds an orchestrator with the Meilisearch post-hook wired in.
|
||||
|
||||
@@ -48,6 +48,10 @@ func (s *stubConsumer) ClaimNextAudioTask(_ context.Context, _ string) (domain.A
|
||||
return t, true, nil
|
||||
}
|
||||
|
||||
func (s *stubConsumer) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
|
||||
return domain.TranslationTask{}, false, nil
|
||||
}
|
||||
|
||||
func (s *stubConsumer) FinishScrapeTask(_ context.Context, id string, _ domain.ScrapeResult) error {
|
||||
s.finished = append(s.finished, id)
|
||||
return nil
|
||||
@@ -58,6 +62,11 @@ func (s *stubConsumer) FinishAudioTask(_ context.Context, id string, _ domain.Au
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubConsumer) FinishTranslationTask(_ context.Context, id string, _ domain.TranslationResult) error {
|
||||
s.finished = append(s.finished, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubConsumer) FailTask(_ context.Context, id, _ string) error {
|
||||
s.failCalled = append(s.failCalled, id)
|
||||
return nil
|
||||
|
||||
97
backend/internal/runner/translation.go
Normal file
97
backend/internal/runner/translation.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
)
|
||||
|
||||
// runTranslationTask executes one machine-translation task end-to-end and
|
||||
// reports the result back to PocketBase.
|
||||
func (r *Runner) runTranslationTask(ctx context.Context, task domain.TranslationTask) {
|
||||
ctx, span := otel.Tracer("runner").Start(ctx, "runner.translation_task")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("task.id", task.ID),
|
||||
attribute.String("book.slug", task.Slug),
|
||||
attribute.Int("chapter.number", task.Chapter),
|
||||
attribute.String("translation.lang", task.Lang),
|
||||
)
|
||||
|
||||
log := r.deps.Log.With("task_id", task.ID, "slug", task.Slug, "chapter", task.Chapter, "lang", task.Lang)
|
||||
log.Info("runner: translation task starting")
|
||||
|
||||
// Heartbeat goroutine — keeps the task alive while translation runs.
|
||||
hbCtx, hbCancel := context.WithCancel(ctx)
|
||||
defer hbCancel()
|
||||
go func() {
|
||||
tick := time.NewTicker(r.cfg.HeartbeatInterval)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-hbCtx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
if err := r.deps.Consumer.HeartbeatTask(ctx, task.ID); err != nil {
|
||||
log.Warn("runner: heartbeat failed", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
fail := func(msg string) {
|
||||
log.Error("runner: translation task failed", "reason", msg)
|
||||
r.tasksFailed.Add(1)
|
||||
span.SetStatus(codes.Error, msg)
|
||||
result := domain.TranslationResult{ErrorMessage: msg}
|
||||
if err := r.deps.Consumer.FinishTranslationTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishTranslationTask failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: LibreTranslate must be configured.
|
||||
if r.deps.LibreTranslate == nil {
|
||||
fail("libretranslate client not configured (LIBRETRANSLATE_URL is empty)")
|
||||
return
|
||||
}
|
||||
|
||||
// 1. Read raw markdown chapter.
|
||||
raw, err := r.deps.BookReader.ReadChapter(ctx, task.Slug, task.Chapter)
|
||||
if err != nil {
|
||||
fail(fmt.Sprintf("read chapter: %v", err))
|
||||
return
|
||||
}
|
||||
if raw == "" {
|
||||
fail("chapter text is empty")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Translate (chunked, concurrent).
|
||||
translated, err := r.deps.LibreTranslate.Translate(ctx, raw, "en", task.Lang)
|
||||
if err != nil {
|
||||
fail(fmt.Sprintf("translate: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Store translated markdown in MinIO.
|
||||
key := r.deps.TranslationStore.TranslationObjectKey(task.Lang, task.Slug, task.Chapter)
|
||||
if err := r.deps.TranslationStore.PutTranslation(ctx, key, []byte(translated)); err != nil {
|
||||
fail(fmt.Sprintf("put translation: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Report success.
|
||||
r.tasksCompleted.Add(1)
|
||||
span.SetStatus(codes.Ok, "")
|
||||
result := domain.TranslationResult{ObjectKey: key}
|
||||
if err := r.deps.Consumer.FinishTranslationTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishTranslationTask failed", "err", err)
|
||||
}
|
||||
log.Info("runner: translation task finished", "key", key)
|
||||
}
|
||||
@@ -17,12 +17,13 @@ import (
|
||||
|
||||
// minioClient wraps the official minio-go client with bucket names.
|
||||
type minioClient struct {
|
||||
client *minio.Client // internal — all read/write operations
|
||||
pubClient *minio.Client // presign-only — initialised against the public endpoint
|
||||
bucketChapters string
|
||||
bucketAudio string
|
||||
bucketAvatars string
|
||||
bucketBrowse string
|
||||
client *minio.Client // internal — all read/write operations
|
||||
pubClient *minio.Client // presign-only — initialised against the public endpoint
|
||||
bucketChapters string
|
||||
bucketAudio string
|
||||
bucketAvatars string
|
||||
bucketBrowse string
|
||||
bucketTranslations string
|
||||
}
|
||||
|
||||
func newMinioClient(cfg config.MinIO) (*minioClient, error) {
|
||||
@@ -74,18 +75,19 @@ func newMinioClient(cfg config.MinIO) (*minioClient, error) {
|
||||
}
|
||||
|
||||
return &minioClient{
|
||||
client: internal,
|
||||
pubClient: pub,
|
||||
bucketChapters: cfg.BucketChapters,
|
||||
bucketAudio: cfg.BucketAudio,
|
||||
bucketAvatars: cfg.BucketAvatars,
|
||||
bucketBrowse: cfg.BucketBrowse,
|
||||
client: internal,
|
||||
pubClient: pub,
|
||||
bucketChapters: cfg.BucketChapters,
|
||||
bucketAudio: cfg.BucketAudio,
|
||||
bucketAvatars: cfg.BucketAvatars,
|
||||
bucketBrowse: cfg.BucketBrowse,
|
||||
bucketTranslations: cfg.BucketTranslations,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensureBuckets creates all required buckets if they don't already exist.
|
||||
func (m *minioClient) ensureBuckets(ctx context.Context) error {
|
||||
for _, bucket := range []string{m.bucketChapters, m.bucketAudio, m.bucketAvatars, m.bucketBrowse} {
|
||||
for _, bucket := range []string{m.bucketChapters, m.bucketAudio, m.bucketAvatars, m.bucketBrowse, m.bucketTranslations} {
|
||||
exists, err := m.client.BucketExists(ctx, bucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("minio: check bucket %q: %w", bucket, err)
|
||||
@@ -125,6 +127,12 @@ func CoverObjectKey(slug string) string {
|
||||
return fmt.Sprintf("covers/%s.jpg", slug)
|
||||
}
|
||||
|
||||
// TranslationObjectKey returns the MinIO object key for a translated chapter.
|
||||
// Format: {lang}/{slug}/{n:06d}.md
|
||||
func TranslationObjectKey(lang, slug string, n int) string {
|
||||
return fmt.Sprintf("%s/%s/%06d.md", lang, slug, n)
|
||||
}
|
||||
|
||||
// chapterNumberFromKey extracts the chapter number from a MinIO object key.
|
||||
// e.g. "my-book/chapter-000042.md" → 42
|
||||
func chapterNumberFromKey(key string) int {
|
||||
|
||||
@@ -51,6 +51,7 @@ var _ bookstore.AudioStore = (*Store)(nil)
|
||||
var _ bookstore.PresignStore = (*Store)(nil)
|
||||
var _ bookstore.ProgressStore = (*Store)(nil)
|
||||
var _ bookstore.CoverStore = (*Store)(nil)
|
||||
var _ bookstore.TranslationStore = (*Store)(nil)
|
||||
var _ taskqueue.Producer = (*Store)(nil)
|
||||
var _ taskqueue.Consumer = (*Store)(nil)
|
||||
var _ taskqueue.Reader = (*Store)(nil)
|
||||
@@ -535,13 +536,36 @@ func (s *Store) CreateAudioTask(ctx context.Context, slug string, chapter int, v
|
||||
return rec.ID, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateTranslationTask(ctx context.Context, slug string, chapter int, lang string) (string, error) {
|
||||
cacheKey := fmt.Sprintf("%s/%d/%s", slug, chapter, lang)
|
||||
payload := map[string]any{
|
||||
"cache_key": cacheKey,
|
||||
"slug": slug,
|
||||
"chapter": chapter,
|
||||
"lang": lang,
|
||||
"status": string(domain.TaskStatusPending),
|
||||
"started": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := s.pb.post(ctx, "/api/collections/translation_jobs/records", payload, &rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rec.ID, nil
|
||||
}
|
||||
|
||||
func (s *Store) CancelTask(ctx context.Context, id string) error {
|
||||
// Try scraping_tasks first, then audio_jobs.
|
||||
// Try scraping_tasks first, then audio_jobs, then translation_jobs.
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id),
|
||||
map[string]string{"status": string(domain.TaskStatusCancelled)}); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id),
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id),
|
||||
map[string]string{"status": string(domain.TaskStatusCancelled)}); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id),
|
||||
map[string]string{"status": string(domain.TaskStatusCancelled)})
|
||||
}
|
||||
|
||||
@@ -571,6 +595,18 @@ func (s *Store) ClaimNextAudioTask(ctx context.Context, workerID string) (domain
|
||||
return task, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) ClaimNextTranslationTask(ctx context.Context, workerID string) (domain.TranslationTask, bool, error) {
|
||||
raw, err := s.pb.claimRecord(ctx, "translation_jobs", workerID, nil)
|
||||
if err != nil {
|
||||
return domain.TranslationTask{}, false, err
|
||||
}
|
||||
if raw == nil {
|
||||
return domain.TranslationTask{}, false, nil
|
||||
}
|
||||
task, err := parseTranslationTask(raw)
|
||||
return task, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error {
|
||||
status := string(domain.TaskStatusDone)
|
||||
if result.ErrorMessage != "" {
|
||||
@@ -599,6 +635,18 @@ func (s *Store) FinishAudioTask(ctx context.Context, id string, result domain.Au
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) FinishTranslationTask(ctx context.Context, id string, result domain.TranslationResult) error {
|
||||
status := string(domain.TaskStatusDone)
|
||||
if result.ErrorMessage != "" {
|
||||
status = string(domain.TaskStatusFailed)
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), map[string]any{
|
||||
"status": status,
|
||||
"error_message": result.ErrorMessage,
|
||||
"finished": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) FailTask(ctx context.Context, id, errMsg string) error {
|
||||
payload := map[string]any{
|
||||
"status": string(domain.TaskStatusFailed),
|
||||
@@ -608,11 +656,14 @@ func (s *Store) FailTask(ctx context.Context, id, errMsg string) error {
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), payload); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload)
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), payload)
|
||||
}
|
||||
|
||||
// HeartbeatTask updates the heartbeat_at field on a running task.
|
||||
// Tries scraping_tasks first, then audio_jobs (same pattern as FailTask).
|
||||
// Tries scraping_tasks first, then audio_jobs, then translation_jobs.
|
||||
func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
|
||||
payload := map[string]any{
|
||||
"heartbeat_at": time.Now().UTC().Format(time.RFC3339),
|
||||
@@ -620,7 +671,10 @@ func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id), payload); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload)
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), payload)
|
||||
}
|
||||
|
||||
// ReapStaleTasks finds all running tasks whose heartbeat_at is either missing
|
||||
@@ -638,7 +692,7 @@ func (s *Store) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (i
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, collection := range []string{"scraping_tasks", "audio_jobs"} {
|
||||
for _, collection := range []string{"scraping_tasks", "audio_jobs", "translation_jobs"} {
|
||||
items, err := s.pb.listAll(ctx, collection, filter, "")
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("ReapStaleTasks list %s: %w", collection, err)
|
||||
@@ -715,6 +769,31 @@ func (s *Store) GetAudioTask(ctx context.Context, cacheKey string) (domain.Audio
|
||||
return t, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) ListTranslationTasks(ctx context.Context) ([]domain.TranslationTask, error) {
|
||||
items, err := s.pb.listAll(ctx, "translation_jobs", "", "-started")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks := make([]domain.TranslationTask, 0, len(items))
|
||||
for _, raw := range items {
|
||||
t, err := parseTranslationTask(raw)
|
||||
if err == nil {
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetTranslationTask(ctx context.Context, cacheKey string) (domain.TranslationTask, bool, error) {
|
||||
filter := fmt.Sprintf(`cache_key='%s'`, cacheKey)
|
||||
items, err := s.pb.listAll(ctx, "translation_jobs", filter, "-started")
|
||||
if err != nil || len(items) == 0 {
|
||||
return domain.TranslationTask{}, false, err
|
||||
}
|
||||
t, err := parseTranslationTask(items[0])
|
||||
return t, err == nil, err
|
||||
}
|
||||
|
||||
// ── Parsers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func parseScrapeTask(raw json.RawMessage) (domain.ScrapeTask, error) {
|
||||
@@ -789,6 +868,38 @@ func parseAudioTask(raw json.RawMessage) (domain.AudioTask, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseTranslationTask(raw json.RawMessage) (domain.TranslationTask, error) {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
CacheKey string `json:"cache_key"`
|
||||
Slug string `json:"slug"`
|
||||
Chapter int `json:"chapter"`
|
||||
Lang string `json:"lang"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Started string `json:"started"`
|
||||
Finished string `json:"finished"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &rec); err != nil {
|
||||
return domain.TranslationTask{}, err
|
||||
}
|
||||
started, _ := time.Parse(time.RFC3339, rec.Started)
|
||||
finished, _ := time.Parse(time.RFC3339, rec.Finished)
|
||||
return domain.TranslationTask{
|
||||
ID: rec.ID,
|
||||
CacheKey: rec.CacheKey,
|
||||
Slug: rec.Slug,
|
||||
Chapter: rec.Chapter,
|
||||
Lang: rec.Lang,
|
||||
WorkerID: rec.WorkerID,
|
||||
Status: domain.TaskStatus(rec.Status),
|
||||
ErrorMessage: rec.ErrorMessage,
|
||||
Started: started,
|
||||
Finished: finished,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── CoverStore ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) PutCover(ctx context.Context, slug string, data []byte, contentType string) error {
|
||||
@@ -818,3 +929,25 @@ func (s *Store) GetCover(ctx context.Context, slug string) ([]byte, string, bool
|
||||
func (s *Store) CoverExists(ctx context.Context, slug string) bool {
|
||||
return s.mc.coverExists(ctx, CoverObjectKey(slug))
|
||||
}
|
||||
|
||||
// ── TranslationStore ───────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) TranslationObjectKey(lang, slug string, n int) string {
|
||||
return TranslationObjectKey(lang, slug, n)
|
||||
}
|
||||
|
||||
func (s *Store) TranslationExists(ctx context.Context, key string) bool {
|
||||
return s.mc.objectExists(ctx, s.mc.bucketTranslations, key)
|
||||
}
|
||||
|
||||
func (s *Store) PutTranslation(ctx context.Context, key string, data []byte) error {
|
||||
return s.mc.putObject(ctx, s.mc.bucketTranslations, key, "text/markdown; charset=utf-8", data)
|
||||
}
|
||||
|
||||
func (s *Store) GetTranslation(ctx context.Context, key string) (string, error) {
|
||||
data, err := s.mc.getObject(ctx, s.mc.bucketTranslations, key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("GetTranslation: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ type Producer interface {
|
||||
// returns the assigned PocketBase record ID.
|
||||
CreateAudioTask(ctx context.Context, slug string, chapter int, voice string) (string, error)
|
||||
|
||||
// CreateTranslationTask inserts a new translation task with status=pending and
|
||||
// returns the assigned PocketBase record ID.
|
||||
CreateTranslationTask(ctx context.Context, slug string, chapter int, lang string) (string, error)
|
||||
|
||||
// CancelTask transitions a pending task to status=cancelled.
|
||||
// Returns ErrNotFound if the task does not exist.
|
||||
CancelTask(ctx context.Context, id string) error
|
||||
@@ -46,13 +50,21 @@ type Consumer interface {
|
||||
// Returns (zero, false, nil) when the queue is empty.
|
||||
ClaimNextAudioTask(ctx context.Context, workerID string) (domain.AudioTask, bool, error)
|
||||
|
||||
// ClaimNextTranslationTask atomically finds the oldest pending translation task,
|
||||
// sets its status=running and worker_id=workerID, and returns it.
|
||||
// Returns (zero, false, nil) when the queue is empty.
|
||||
ClaimNextTranslationTask(ctx context.Context, workerID string) (domain.TranslationTask, bool, error)
|
||||
|
||||
// FinishScrapeTask marks a running scrape task as done and records the result.
|
||||
FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error
|
||||
|
||||
// FinishAudioTask marks a running audio task as done and records the result.
|
||||
FinishAudioTask(ctx context.Context, id string, result domain.AudioResult) error
|
||||
|
||||
// FailTask marks a task (scrape or audio) as failed with an error message.
|
||||
// FinishTranslationTask marks a running translation task as done and records the result.
|
||||
FinishTranslationTask(ctx context.Context, id string, result domain.TranslationResult) error
|
||||
|
||||
// FailTask marks a task (scrape, audio, or translation) as failed with an error message.
|
||||
FailTask(ctx context.Context, id, errMsg string) error
|
||||
|
||||
// HeartbeatTask updates the heartbeat_at timestamp on a running task.
|
||||
@@ -81,4 +93,11 @@ type Reader interface {
|
||||
// GetAudioTask returns the most recent audio task for cacheKey.
|
||||
// Returns (zero, false, nil) if not found.
|
||||
GetAudioTask(ctx context.Context, cacheKey string) (domain.AudioTask, bool, error)
|
||||
|
||||
// ListTranslationTasks returns all translation tasks sorted by started descending.
|
||||
ListTranslationTasks(ctx context.Context) ([]domain.TranslationTask, error)
|
||||
|
||||
// GetTranslationTask returns the most recent translation task for cacheKey.
|
||||
// Returns (zero, false, nil) if not found.
|
||||
GetTranslationTask(ctx context.Context, cacheKey string) (domain.TranslationTask, bool, error)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ func (s *stubStore) CreateScrapeTask(_ context.Context, _, _ string, _, _ int) (
|
||||
func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string) (string, error) {
|
||||
return "audio-1", nil
|
||||
}
|
||||
func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) {
|
||||
return "translation-1", nil
|
||||
}
|
||||
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
|
||||
|
||||
func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
|
||||
@@ -31,12 +34,18 @@ func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.Scr
|
||||
func (s *stubStore) ClaimNextAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) {
|
||||
return domain.AudioTask{ID: "audio-1", Status: domain.TaskStatusRunning}, true, nil
|
||||
}
|
||||
func (s *stubStore) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
|
||||
return domain.TranslationTask{ID: "translation-1", Status: domain.TaskStatusRunning}, true, nil
|
||||
}
|
||||
func (s *stubStore) FinishScrapeTask(_ context.Context, _ string, _ domain.ScrapeResult) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubStore) FinishAudioTask(_ context.Context, _ string, _ domain.AudioResult) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubStore) FinishTranslationTask(_ context.Context, _ string, _ domain.TranslationResult) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubStore) FailTask(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
func (s *stubStore) HeartbeatTask(_ context.Context, _ string) error { return nil }
|
||||
@@ -53,6 +62,12 @@ func (s *stubStore) ListAudioTasks(_ context.Context) ([]domain.AudioTask, error
|
||||
func (s *stubStore) GetAudioTask(_ context.Context, _ string) (domain.AudioTask, bool, error) {
|
||||
return domain.AudioTask{}, false, nil
|
||||
}
|
||||
func (s *stubStore) ListTranslationTasks(_ context.Context) ([]domain.TranslationTask, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *stubStore) GetTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
|
||||
return domain.TranslationTask{}, false, nil
|
||||
}
|
||||
|
||||
// Verify the stub satisfies all three interfaces at compile time.
|
||||
var _ taskqueue.Producer = (*stubStore)(nil)
|
||||
|
||||
BIN
backend/runner
Executable file
BIN
backend/runner
Executable file
Binary file not shown.
139
caddy/errors/404.html
Normal file
139
caddy/errors/404.html
Normal file
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>404 — Page Not Found — LibNovel</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #27272a;
|
||||
}
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.watermark {
|
||||
font-size: clamp(5rem, 22vw, 9rem);
|
||||
font-weight: 800;
|
||||
color: #18181b;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
user-select: none;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #71717a;
|
||||
}
|
||||
.status-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.9375rem;
|
||||
max-width: 38ch;
|
||||
line-height: 1.65;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.625rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn:hover { background: #d97706; }
|
||||
|
||||
footer {
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid #27272a;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #3f3f46;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="watermark">404</div>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="dot"></div>
|
||||
<span class="status-label">Page not found</span>
|
||||
</div>
|
||||
|
||||
<h1>Nothing here</h1>
|
||||
<p>The page you're looking for doesn't exist or has been moved.</p>
|
||||
|
||||
<a class="btn" href="/">Go home</a>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
© LibNovel
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,49 +3,160 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>502 — Service Unavailable</title>
|
||||
<title>502 — Service Unavailable — LibNovel</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #27272a;
|
||||
}
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
background: #09090b;
|
||||
color: #a1a1aa;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
padding: 2rem;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
gap: 0;
|
||||
}
|
||||
.code {
|
||||
font-size: clamp(4rem, 20vw, 8rem);
|
||||
|
||||
.watermark {
|
||||
font-size: clamp(5rem, 22vw, 9rem);
|
||||
font-weight: 800;
|
||||
color: #27272a;
|
||||
color: #18181b;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
user-select: none;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
h1 { font-size: 1.25rem; font-weight: 600; color: #e4e4e7; }
|
||||
p { font-size: 0.9rem; max-width: 36ch; line-height: 1.6; }
|
||||
a {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.75); }
|
||||
}
|
||||
.status-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.9375rem;
|
||||
max-width: 38ch;
|
||||
line-height: 1.65;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.6rem 1.4rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn:hover { background: #d97706; }
|
||||
|
||||
.refresh-note {
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #52525b;
|
||||
}
|
||||
#countdown { color: #71717a; }
|
||||
|
||||
footer {
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid #27272a;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #3f3f46;
|
||||
}
|
||||
a:hover { background: #d97706; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="code">502</div>
|
||||
<h1>Service Unavailable</h1>
|
||||
<p>The server is temporarily unreachable. Please try again in a moment.</p>
|
||||
<a href="/">Go home</a>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="watermark">502</div>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="dot"></div>
|
||||
<span class="status-label">Service unavailable</span>
|
||||
</div>
|
||||
|
||||
<h1>Something went wrong</h1>
|
||||
<p>The server is temporarily unreachable. This usually resolves itself quickly.</p>
|
||||
|
||||
<a class="btn" href="/">Try again</a>
|
||||
<p class="refresh-note">Page refreshes automatically in <span id="countdown">20</span>s</p>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
© LibNovel
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
var s = 20;
|
||||
var el = document.getElementById('countdown');
|
||||
var t = setInterval(function () {
|
||||
s--;
|
||||
el.textContent = s;
|
||||
if (s <= 0) { clearInterval(t); location.reload(); }
|
||||
}, 1000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,49 +3,163 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>503 — Maintenance</title>
|
||||
<title>Under Maintenance — LibNovel</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #27272a;
|
||||
}
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
/* ── Main ── */
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
background: #09090b;
|
||||
color: #a1a1aa;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
padding: 2rem;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
gap: 0;
|
||||
}
|
||||
.code {
|
||||
font-size: clamp(4rem, 20vw, 8rem);
|
||||
|
||||
.watermark {
|
||||
font-size: clamp(5rem, 22vw, 9rem);
|
||||
font-weight: 800;
|
||||
color: #27272a;
|
||||
color: #18181b;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
user-select: none;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
h1 { font-size: 1.25rem; font-weight: 600; color: #e4e4e7; }
|
||||
p { font-size: 0.9rem; max-width: 36ch; line-height: 1.6; }
|
||||
a {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.75); }
|
||||
}
|
||||
.status-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.9375rem;
|
||||
max-width: 38ch;
|
||||
line-height: 1.65;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.6rem 1.4rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn:hover { background: #d97706; }
|
||||
|
||||
.refresh-note {
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #52525b;
|
||||
}
|
||||
#countdown { color: #71717a; }
|
||||
|
||||
/* ── Footer ── */
|
||||
footer {
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid #27272a;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #3f3f46;
|
||||
}
|
||||
a:hover { background: #d97706; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="code">503</div>
|
||||
<h1>Under Maintenance</h1>
|
||||
<p>LibNovel is briefly offline for maintenance. We’ll be back shortly.</p>
|
||||
<a href="/">Try again</a>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="watermark">503</div>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="dot"></div>
|
||||
<span class="status-label">Maintenance in progress</span>
|
||||
</div>
|
||||
|
||||
<h1>We'll be right back</h1>
|
||||
<p>LibNovel is briefly offline for scheduled maintenance. No data is being changed — hang tight.</p>
|
||||
|
||||
<a class="btn" href="/">Try again</a>
|
||||
<p class="refresh-note">Page refreshes automatically in <span id="countdown">30</span>s</p>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
© LibNovel
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
var s = 30;
|
||||
var el = document.getElementById('countdown');
|
||||
var t = setInterval(function () {
|
||||
s--;
|
||||
el.textContent = s;
|
||||
if (s <= 0) { clearInterval(t); location.reload(); }
|
||||
}, 1000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,49 +3,160 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>504 — Gateway Timeout</title>
|
||||
<title>504 — Gateway Timeout — LibNovel</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: #09090b;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid #27272a;
|
||||
}
|
||||
.logo {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logo span { color: #f59e0b; }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
background: #09090b;
|
||||
color: #a1a1aa;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
padding: 2rem;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
gap: 0;
|
||||
}
|
||||
.code {
|
||||
font-size: clamp(4rem, 20vw, 8rem);
|
||||
|
||||
.watermark {
|
||||
font-size: clamp(5rem, 22vw, 9rem);
|
||||
font-weight: 800;
|
||||
color: #27272a;
|
||||
color: #18181b;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
user-select: none;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
h1 { font-size: 1.25rem; font-weight: 600; color: #e4e4e7; }
|
||||
p { font-size: 0.9rem; max-width: 36ch; line-height: 1.6; }
|
||||
a {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(0.75); }
|
||||
}
|
||||
.status-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #e4e4e7;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.9375rem;
|
||||
max-width: 38ch;
|
||||
line-height: 1.65;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.6rem 1.4rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f59e0b;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn:hover { background: #d97706; }
|
||||
|
||||
.refresh-note {
|
||||
margin-top: 1.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #52525b;
|
||||
}
|
||||
#countdown { color: #71717a; }
|
||||
|
||||
footer {
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid #27272a;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #3f3f46;
|
||||
}
|
||||
a:hover { background: #d97706; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="code">504</div>
|
||||
<h1>Gateway Timeout</h1>
|
||||
<p>The request took too long to complete. Please refresh and try again.</p>
|
||||
<a href="/">Go home</a>
|
||||
|
||||
<header>
|
||||
<a class="logo" href="/">Lib<span>Novel</span></a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="watermark">504</div>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="dot"></div>
|
||||
<span class="status-label">Gateway timeout</span>
|
||||
</div>
|
||||
|
||||
<h1>Request timed out</h1>
|
||||
<p>The server took too long to respond. Please refresh and try again.</p>
|
||||
|
||||
<a class="btn" href="/">Try again</a>
|
||||
<p class="refresh-note">Page refreshes automatically in <span id="countdown">20</span>s</p>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
© LibNovel
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
var s = 20;
|
||||
var el = document.getElementById('countdown');
|
||||
var t = setInterval(function () {
|
||||
s--;
|
||||
el.textContent = s;
|
||||
if (s <= 0) { clearInterval(t); location.reload(); }
|
||||
}, 1000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -154,7 +154,7 @@ services:
|
||||
# No public port — all traffic is routed via Caddy.
|
||||
expose:
|
||||
- "8080"
|
||||
environment:
|
||||
environment:
|
||||
<<: *infra-env
|
||||
BACKEND_HTTP_ADDR: ":8080"
|
||||
LOG_LEVEL: "${LOG_LEVEL}"
|
||||
@@ -224,6 +224,7 @@ services:
|
||||
# Kokoro-FastAPI TTS endpoint
|
||||
KOKORO_URL: "${KOKORO_URL}"
|
||||
KOKORO_VOICE: "${KOKORO_VOICE}"
|
||||
POCKET_TTS_URL: "${POCKET_TTS_URL}"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
|
||||
OTEL_SERVICE_NAME: "runner"
|
||||
@@ -400,15 +401,19 @@ services:
|
||||
# ─── Watchtower (auto-redeploy custom services on new images) ────────────────
|
||||
# Only watches services labelled com.centurylinklabs.watchtower.enable=true.
|
||||
# Third-party infra images (minio, pocketbase, meilisearch, etc.) are excluded.
|
||||
# doppler binary is mounted from the host so watchtower fetches fresh secrets
|
||||
# on every start (notification URL, credentials) without baking them in.
|
||||
watchtower:
|
||||
image: containrrr/watchtower:latest
|
||||
restart: unless-stopped
|
||||
entrypoint: ["/usr/bin/doppler", "run", "--project", "libnovel", "--config", "prd", "--"]
|
||||
command: ["/watchtower", "--label-enable", "--interval", "300", "--cleanup"]
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
command: --label-enable --interval 300 --cleanup
|
||||
- /usr/bin/doppler:/usr/bin/doppler:ro
|
||||
- /root/.doppler:/root/.doppler:ro
|
||||
environment:
|
||||
WATCHTOWER_NOTIFICATIONS: "${WATCHTOWER_NOTIFICATIONS}"
|
||||
WATCHTOWER_NOTIFICATION_URL: "${WATCHTOWER_NOTIFICATION_URL}"
|
||||
HOME: "/root"
|
||||
DOCKER_API_VERSION: "1.44"
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -35,11 +35,11 @@ client: Browser / iOS App {
|
||||
caddy: Caddy :443 {
|
||||
shape: rectangle
|
||||
style.fill: "#f1f5f9"
|
||||
label: "Caddy :443\ncustom build · caddy-ratelimit\nsecurity headers · rate limiting\nstatic error pages"
|
||||
label: "Caddy :443\ncustom build · caddy-l4 · caddy-ratelimit\nCrowdSec bouncer · security headers\nrate limiting · static error pages\nRedis TCP proxy :6380"
|
||||
}
|
||||
|
||||
# ─── SvelteKit UI ─────────────────────────────────────────────────────────────
|
||||
# Handles: auth enforcement, session, all /api/* routes that have SK counterparts
|
||||
# All routes here pass through SvelteKit — auth is enforced server-side.
|
||||
|
||||
sk: SvelteKit UI :3000 {
|
||||
style.fill: "#fef3c7"
|
||||
@@ -53,7 +53,7 @@ sk: SvelteKit UI :3000 {
|
||||
catalogue_sk: Catalogue {
|
||||
style.fill: "#f0fdf4"
|
||||
style.stroke: "#86efac"
|
||||
label: "GET /api/catalogue-page\nGET /api/search"
|
||||
label: "GET /api/catalogue-page (infinite scroll)\nGET /api/search"
|
||||
}
|
||||
|
||||
book_sk: Book {
|
||||
@@ -65,7 +65,7 @@ sk: SvelteKit UI :3000 {
|
||||
scrape_sk: Scrape (admin) {
|
||||
style.fill: "#fff7ed"
|
||||
style.stroke: "#fdba74"
|
||||
label: "GET /api/scrape/status\nGET /api/scrape/tasks\nPOST /api/scrape\nPOST /api/scrape/range\nPOST /api/scrape/cancel/{id}"
|
||||
label: "GET /api/scrape/status\nGET /api/scrape/tasks\nPOST /api/scrape\nPOST /api/scrape/book\nPOST /api/scrape/book/range\nPOST /api/scrape/cancel/{id}"
|
||||
}
|
||||
|
||||
audio_sk: Audio {
|
||||
@@ -74,7 +74,7 @@ sk: SvelteKit UI :3000 {
|
||||
label: "POST /api/audio/{slug}/{n}\nGET /api/audio/status/{slug}/{n}\nGET /api/voices"
|
||||
}
|
||||
|
||||
presign_sk: Presigned URLs {
|
||||
presign_sk: Presigned URLs (public) {
|
||||
style.fill: "#f0fdf4"
|
||||
style.stroke: "#86efac"
|
||||
label: "GET /api/presign/chapter/{slug}/{n}\nGET /api/presign/audio/{slug}/{n}\nGET /api/presign/voice-sample/{voice}"
|
||||
@@ -106,12 +106,12 @@ sk: SvelteKit UI :3000 {
|
||||
}
|
||||
|
||||
# ─── Go Backend ───────────────────────────────────────────────────────────────
|
||||
# Caddy proxies these paths directly — no SvelteKit auth layer
|
||||
# Caddy proxies these paths directly — bypasses SvelteKit entirely.
|
||||
|
||||
be: Backend API :8080 {
|
||||
style.fill: "#eef3ff"
|
||||
|
||||
health_be: Health {
|
||||
health_be: Health / Version {
|
||||
style.fill: "#f0fdf4"
|
||||
style.stroke: "#86efac"
|
||||
label: "GET /health\nGET /api/version"
|
||||
@@ -126,7 +126,7 @@ be: Backend API :8080 {
|
||||
catalogue_be: Catalogue {
|
||||
style.fill: "#f0fdf4"
|
||||
style.stroke: "#86efac"
|
||||
label: "GET /api/browse\nGET /api/catalogue\nGET /api/ranking\nGET /api/cover/{domain}/{slug}"
|
||||
label: "GET /api/catalogue (Meilisearch)\nGET /api/browse (legacy MinIO cache)\nGET /api/ranking\nGET /api/cover/{domain}/{slug}"
|
||||
}
|
||||
|
||||
book_be: Book / Chapter {
|
||||
@@ -138,7 +138,13 @@ be: Backend API :8080 {
|
||||
audio_be: Audio {
|
||||
style.fill: "#f0fdf4"
|
||||
style.stroke: "#86efac"
|
||||
label: "GET /api/audio-proxy/{slug}/{n}\nGET /api/voices"
|
||||
label: "POST /api/audio/{slug}/{n}\nGET /api/audio/status/{slug}/{n}\nGET /api/audio-proxy/{slug}/{n}\nGET /api/voices"
|
||||
}
|
||||
|
||||
presign_be: Presigned URLs {
|
||||
style.fill: "#f0fdf4"
|
||||
style.stroke: "#86efac"
|
||||
label: "GET /api/presign/chapter/{slug}/{n}\nGET /api/presign/audio/{slug}/{n}\nGET /api/presign/voice-sample/{voice}\nGET /api/presign/avatar-upload/{userId}\nGET /api/presign/avatar/{userId}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,19 +155,19 @@ storage: Storage {
|
||||
|
||||
pb: PocketBase :8090 {
|
||||
shape: cylinder
|
||||
label: "auth · books · progress\ncomments · library\nscrape_jobs · audio_cache"
|
||||
label: "auth · books · progress\ncomments · library\nscrape_jobs · audio_cache\nranking"
|
||||
}
|
||||
mn: MinIO :9000 {
|
||||
shape: cylinder
|
||||
label: "chapters · audio\navatars · browse"
|
||||
label: "chapters · audio\navatars · catalogue (browse)"
|
||||
}
|
||||
ms: Meilisearch :7700 {
|
||||
shape: cylinder
|
||||
label: "index: books"
|
||||
label: "index: books\nfilterable: status · genres\nsortable: rank · rating\n total_chapters · meta_updated"
|
||||
}
|
||||
vk: Valkey :6379 {
|
||||
shape: cylinder
|
||||
label: "presign URL cache"
|
||||
label: "presign URL cache (TTL ~55 min)\nAsynq job queue (runner)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,18 +175,17 @@ storage: Storage {
|
||||
|
||||
client -> caddy: HTTPS :443
|
||||
|
||||
caddy -> sk: "/* (catch-all)\n→ SvelteKit handles auth"
|
||||
caddy -> be: "/health /scrape*\n/api/browse /api/book-preview/*\n/api/chapter-text/* /api/chapter-markdown/*\n/api/reindex/* /api/cover/*\n/api/audio-proxy/* /api/catalogue /api/ranking"
|
||||
caddy -> storage.mn: "/avatars/*\n/audio/*\n/chapters/*\n(presigned MinIO GETs)"
|
||||
caddy -> sk: "/* (catch-all)\n→ SvelteKit enforces auth"
|
||||
caddy -> be: "/health /scrape*\n/api/browse /api/catalogue /api/ranking\n/api/version /api/book-preview/*\n/api/chapter-text/* /api/chapter-markdown/*\n/api/reindex/* /api/cover/*\n/api/audio* /api/voices /api/presign/*"
|
||||
caddy -> storage.mn: "/avatars/* /audio/* /chapters/*\n(presigned MinIO GETs)"
|
||||
|
||||
# ─── SvelteKit → Backend (server-side proxy) ──────────────────────────────────
|
||||
|
||||
sk.catalogue_sk -> be.catalogue_be: internal proxy
|
||||
sk.book_sk -> be.book_be: internal proxy
|
||||
sk.audio_sk -> be.audio_be: internal proxy
|
||||
sk.presign_sk -> storage.vk: check cache
|
||||
sk.presign_sk -> storage.mn: generate presign
|
||||
sk.presign_user -> storage.mn: generate presign
|
||||
sk.presign_sk -> be.presign_be: internal proxy
|
||||
sk.presign_user -> be.presign_be: internal proxy
|
||||
|
||||
# ─── SvelteKit → Storage (direct) ────────────────────────────────────────────
|
||||
|
||||
@@ -192,10 +197,12 @@ sk.comments_sk -> storage.pb
|
||||
|
||||
# ─── Backend → Storage ────────────────────────────────────────────────────────
|
||||
|
||||
be.catalogue_be -> storage.ms: full-text search
|
||||
be.catalogue_be -> storage.ms: full-text search + facets
|
||||
be.catalogue_be -> storage.pb: ranking records
|
||||
be.catalogue_be -> storage.mn: cover presign
|
||||
be.book_be -> storage.mn: chapter objects
|
||||
be.book_be -> storage.pb: book metadata
|
||||
be.audio_be -> storage.mn: audio presign
|
||||
be.audio_be -> storage.vk: presign cache
|
||||
be.presign_be -> storage.vk: check / set presign cache
|
||||
be.presign_be -> storage.mn: generate presigned URL
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 60 KiB |
@@ -5,16 +5,25 @@ direction: right
|
||||
novelfire: novelfire.net {
|
||||
shape: cloud
|
||||
style.fill: "#f0f4ff"
|
||||
label: "novelfire.net\n(scrape source)"
|
||||
}
|
||||
|
||||
kokoro: Kokoro-FastAPI TTS {
|
||||
shape: cloud
|
||||
style.fill: "#f0f4ff"
|
||||
label: "Kokoro-FastAPI TTS\n(self-hosted · homelab)\nchapter audio"
|
||||
}
|
||||
|
||||
pockettts: pocket-tts {
|
||||
shape: cloud
|
||||
style.fill: "#f0f4ff"
|
||||
label: "pocket-tts\n(self-hosted · homelab)\nvoice sample MP3s"
|
||||
}
|
||||
|
||||
letsencrypt: Let's Encrypt {
|
||||
shape: cloud
|
||||
style.fill: "#f0f4ff"
|
||||
label: "Let's Encrypt\n(ACME TLS-ALPN-01)"
|
||||
}
|
||||
|
||||
browser: Browser / iOS App {
|
||||
@@ -30,12 +39,12 @@ init: Init containers {
|
||||
|
||||
minio-init: minio-init {
|
||||
shape: rectangle
|
||||
label: "minio-init\n(mc: create buckets)"
|
||||
label: "minio-init\n(mc: create buckets\n chapters · audio\n avatars · catalogue)"
|
||||
}
|
||||
|
||||
pb-init: pb-init {
|
||||
shape: rectangle
|
||||
label: "pb-init\n(bootstrap collections)"
|
||||
label: "pb-init\n(bootstrap PocketBase\n collections + schema)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,109 +55,126 @@ storage: Storage {
|
||||
|
||||
minio: MinIO {
|
||||
shape: cylinder
|
||||
label: "MinIO :9000\n\nbuckets:\n chapters\n audio\n avatars\n catalogue"
|
||||
label: "MinIO :9000\nbuckets:\n chapters · audio\n avatars · catalogue"
|
||||
}
|
||||
|
||||
pocketbase: PocketBase {
|
||||
shape: cylinder
|
||||
label: "PocketBase :8090\n\ncollections:\n books chapters_idx\n audio_cache progress\n scrape_jobs app_users\n ranking"
|
||||
label: "PocketBase :8090\ncollections:\n books · chapters_idx\n audio_cache · progress\n scrape_jobs · app_users\n ranking · library\n comments"
|
||||
}
|
||||
|
||||
valkey: Valkey {
|
||||
shape: cylinder
|
||||
label: "Valkey :6379\n\n(presign URL cache\nTTL-based, shared)"
|
||||
label: "Valkey :6379\npresign URL cache (TTL ~55 min)\nAsynq job queue (runner tasks)"
|
||||
}
|
||||
|
||||
meilisearch: Meilisearch {
|
||||
shape: cylinder
|
||||
label: "Meilisearch :7700\n\nindices:\n books"
|
||||
label: "Meilisearch :7700\nindex: books\n(filterable: status · genres\n sortable: rank · rating\n total_chapters · meta_updated)"
|
||||
}
|
||||
}
|
||||
|
||||
# ─── Application ──────────────────────────────────────────────────────────────
|
||||
# ─── Application — prod VPS (165.22.70.138) ───────────────────────────────────
|
||||
|
||||
app: Application {
|
||||
app: Application — prod (165.22.70.138) {
|
||||
style.fill: "#eef3ff"
|
||||
|
||||
caddy: caddy {
|
||||
shape: rectangle
|
||||
label: "Caddy :443 / :80\ncustom build + caddy-ratelimit\n\nfeatures:\n auto-HTTPS (Let's Encrypt)\n security headers\n rate limiting (per-IP)\n static error pages (502/503/504)"
|
||||
label: "Caddy :443 / :80 / :6380\ncustom build\n+ caddy-l4 (Redis TCP proxy)\n+ caddy-ratelimit\nauto-HTTPS · security headers\nrate limiting (per-IP)\nstatic error pages (404/502/503/504)\nCrowdSec bouncer"
|
||||
}
|
||||
|
||||
backend: backend {
|
||||
shape: rectangle
|
||||
label: "Backend API :8080\n(Go — HTTP API server)"
|
||||
}
|
||||
|
||||
runner: runner {
|
||||
shape: rectangle
|
||||
label: "Runner :9091\n(Go — background worker\nscraping + TTS jobs\n/metrics endpoint)"
|
||||
label: "Backend API :8080\n(Go)\nHTTP API server\nffmpeg (audio sample conv.)\nOpenTelemetry tracing\nSentry / GlitchTip errors"
|
||||
}
|
||||
|
||||
ui: ui {
|
||||
shape: rectangle
|
||||
label: "SvelteKit UI :3000\n(adapter-node)"
|
||||
label: "SvelteKit UI :3000\n(adapter-node)\nSSR · session auth\nserver-side API proxy"
|
||||
}
|
||||
|
||||
crowdsec: CrowdSec {
|
||||
shape: rectangle
|
||||
label: "CrowdSec :8080\nsecurity engine\nreads Caddy JSON logs\nbouncer integrated in Caddy"
|
||||
}
|
||||
|
||||
dozzle: Dozzle agent {
|
||||
shape: rectangle
|
||||
label: "Dozzle agent\n127.0.0.1:7007\nlog relay → homelab dashboard"
|
||||
}
|
||||
}
|
||||
|
||||
# ─── Runner — homelab (192.168.0.109) ────────────────────────────────────────
|
||||
|
||||
homelab: Runner — homelab (192.168.0.109) {
|
||||
style.fill: "#fef9ec"
|
||||
|
||||
runner: runner {
|
||||
shape: rectangle
|
||||
label: "Runner :9091\n(Go background worker)\nscrape pipeline\nTTS audio job queue\nPrometheus /metrics\ncron: catalogue refresh\nAsynq worker → Valkey"
|
||||
}
|
||||
}
|
||||
|
||||
# ─── Ops ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
ops: Ops {
|
||||
style.fill: "#fef9ec"
|
||||
style.fill: "#f5f5f5"
|
||||
|
||||
watchtower: Watchtower {
|
||||
shape: rectangle
|
||||
label: "Watchtower\n(containrrr/watchtower)\n\npolls every 5 min\nautopulls + redeploys:\n backend · runner · ui"
|
||||
label: "Watchtower\n(containrrr/watchtower)\npolls Docker Hub every 5 min\nautopulls + redeploys:\n backend · ui\n(runner: label-disabled on prod)"
|
||||
}
|
||||
}
|
||||
|
||||
# ─── Init → Storage deps ──────────────────────────────────────────────────────
|
||||
# ─── CI / CD ──────────────────────────────────────────────────────────────────
|
||||
|
||||
cicd: CI / CD {
|
||||
style.fill: "#f0f9ff"
|
||||
|
||||
gitea: Gitea Actions {
|
||||
shape: rectangle
|
||||
label: "Gitea Actions\n(homelab runner)\ntag v* trigger:\n test-backend\n check-ui (type-check + build)\n docker-backend\n docker-runner\n docker-ui (bakes releases.json)\n docker-caddy\n → push Docker Hub\n → Gitea Release"
|
||||
}
|
||||
}
|
||||
|
||||
# ─── Init → Storage ───────────────────────────────────────────────────────────
|
||||
|
||||
init.minio-init -> storage.minio: create buckets {style.stroke-dash: 4}
|
||||
init.pb-init -> storage.pocketbase: bootstrap schema {style.stroke-dash: 4}
|
||||
|
||||
# ─── App → Storage ────────────────────────────────────────────────────────────
|
||||
|
||||
app.backend -> storage.minio: blobs (chapters, audio,\navatars, browse)
|
||||
app.backend -> storage.pocketbase: structured records\n(books, progress, jobs…)
|
||||
app.backend -> storage.valkey: cache presigned URLs\n(SET/GET with TTL)
|
||||
|
||||
app.runner -> storage.minio: write chapter markdown\n& audio MP3s
|
||||
app.runner -> storage.pocketbase: read/update scrape jobs\nwrite book records
|
||||
app.runner -> storage.meilisearch: index books on\nscrape completion
|
||||
|
||||
app.ui -> storage.valkey: read presigned URL cache
|
||||
app.ui -> storage.pocketbase: auth, progress,\ncomments, settings
|
||||
|
||||
# ─── App internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
app.ui -> app.backend: REST API calls (server-side)\n/api/catalogue /api/book-preview\n/api/chapter-text /api/audio etc.
|
||||
app.caddy -> app.ui: "/* (catch-all)\nSvelteKit — auth enforced"
|
||||
app.caddy -> app.backend: "/health /scrape*\n/api/browse /api/catalogue\n/api/ranking /api/version\n/api/book-preview/*\n/api/chapter-text/*\n/api/chapter-markdown/*\n/api/reindex/* /api/cover/*\n/api/audio-proxy/* /api/voices\n/api/audio* /api/presign/*"
|
||||
app.caddy -> storage.minio: "/avatars/* /audio/*\n/chapters/*\n(presigned GETs)"
|
||||
app.caddy -> app.crowdsec: bouncer check (15 s poll)
|
||||
app.caddy -> letsencrypt: ACME cert (TLS-ALPN-01)
|
||||
|
||||
# ─── Caddy routing ────────────────────────────────────────────────────────────
|
||||
# Routes sent directly to backend (no SvelteKit counterpart):
|
||||
# /health /scrape*
|
||||
# /api/browse /api/book-preview/* /api/chapter-text/*
|
||||
# /api/reindex/* /api/cover/* /api/audio-proxy/*
|
||||
# Routes sent to MinIO:
|
||||
# /avatars/*
|
||||
# Everything else → SvelteKit UI (including /api/scrape/*, /api/chapter-text-preview/*)
|
||||
app.ui -> app.backend: "internal REST proxy\n(server-side only)"
|
||||
app.ui -> storage.pocketbase: "auth · sessions\nprogress · library\ncomments"
|
||||
|
||||
app.caddy -> app.ui: "/* (catch-all)\n/api/scrape/*\n/api/chapter-text-preview/*\n→ SvelteKit (auth enforced)"
|
||||
app.caddy -> app.backend: "/health /scrape*\n/api/browse /api/book-preview/*\n/api/chapter-text/*\n/api/reindex/* /api/cover/*\n/api/audio-proxy/*"
|
||||
app.caddy -> storage.minio: "/avatars/*\n/audio/*\n/chapters/*\n(presigned MinIO GETs)"
|
||||
app.backend -> storage.minio: "chapter objs · audio MP3s\navatars · browse cache"
|
||||
app.backend -> storage.pocketbase: "books · scrape_jobs\naudio_cache · ranking"
|
||||
app.backend -> storage.valkey: "presign URL cache\n(SET/GET TTL ~55 min)"
|
||||
app.backend -> storage.meilisearch: "catalogue search\nfacets: genres · status"
|
||||
app.backend -> pockettts: "voice sample gen.\n(on-demand · ffmpeg conv.)"
|
||||
|
||||
# ─── External → App ───────────────────────────────────────────────────────────
|
||||
# ─── Runner → deps ────────────────────────────────────────────────────────────
|
||||
|
||||
app.runner -> novelfire: scrape\n(HTTP GET)
|
||||
app.runner -> kokoro: TTS generation\n(HTTP POST)
|
||||
app.caddy -> letsencrypt: ACME certificate\n(TLS-ALPN-01)
|
||||
homelab.runner -> novelfire: "HTTP scrape\nHTML → Markdown"
|
||||
homelab.runner -> kokoro: "TTS generation\ntext → MP3"
|
||||
homelab.runner -> storage.minio: "write chapters\n& audio MP3s"
|
||||
homelab.runner -> storage.pocketbase: "read/update scrape_jobs\nwrite book records"
|
||||
homelab.runner -> storage.meilisearch: "index books\n(on scrape completion)"
|
||||
homelab.runner -> storage.valkey: "Asynq job queue\n(task consume)"
|
||||
|
||||
# ─── Ops → Docker socket ──────────────────────────────────────────────────────
|
||||
|
||||
ops.watchtower -> app.backend: watch (label-enabled)
|
||||
ops.watchtower -> app.runner: watch (label-enabled)
|
||||
ops.watchtower -> app.ui: watch (label-enabled)
|
||||
|
||||
# ─── Browser ──────────────────────────────────────────────────────────────────
|
||||
# ─── Client ───────────────────────────────────────────────────────────────────
|
||||
|
||||
browser -> app.caddy: HTTPS :443\n(single entry point)
|
||||
|
||||
# ─── Ops / CI ─────────────────────────────────────────────────────────────────
|
||||
|
||||
ops.watchtower -> app.backend: watch (label-enabled)
|
||||
ops.watchtower -> app.ui: watch (label-enabled)
|
||||
cicd.gitea -> ops.watchtower: push to Docker Hub\n→ Watchtower detects new tag
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 65 KiB |
@@ -221,7 +221,11 @@ services:
|
||||
EMAIL_SMTP_PORT: "${FIDER_SMTP_PORT}"
|
||||
EMAIL_SMTP_USERNAME: "${FIDER_SMTP_USER}"
|
||||
EMAIL_SMTP_PASSWORD: "${FIDER_SMTP_PASSWORD}"
|
||||
EMAIL_SMTP_ENABLE_STARTTLS: "false"
|
||||
EMAIL_SMTP_ENABLE_STARTTLS: "${FIDER_SMTP_ENABLE_STARTTLS}"
|
||||
OAUTH_GOOGLE_CLIENTID: "${OAUTH_GOOGLE_CLIENTID}"
|
||||
OAUTH_GOOGLE_SECRET: "${OAUTH_GOOGLE_SECRET}"
|
||||
OAUTH_GITHUB_CLIENTID: "${OAUTH_GITHUB_CLIENTID}"
|
||||
OAUTH_GITHUB_SECRET: "${OAUTH_GITHUB_SECRET}"
|
||||
|
||||
# ── Dozzle ──────────────────────────────────────────────────────────────────
|
||||
# Watches both homelab and prod containers.
|
||||
@@ -439,15 +443,19 @@ services:
|
||||
# ── Watchtower ──────────────────────────────────────────────────────────────
|
||||
# Auto-updates runner image when CI pushes a new tag.
|
||||
# Only watches services with the watchtower label.
|
||||
# doppler binary is mounted from the host so watchtower fetches fresh secrets
|
||||
# on every start (notification URL, credentials) without baking them in.
|
||||
watchtower:
|
||||
image: containrrr/watchtower:latest
|
||||
restart: unless-stopped
|
||||
entrypoint: ["/usr/bin/doppler", "run", "--project", "libnovel", "--config", "prd_homelab", "--"]
|
||||
command: ["/watchtower", "--label-enable", "--interval", "300", "--cleanup"]
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
command: --label-enable --interval 300 --cleanup
|
||||
- /usr/bin/doppler:/usr/bin/doppler:ro
|
||||
- /root/.doppler:/root/.doppler:ro
|
||||
environment:
|
||||
WATCHTOWER_NOTIFICATIONS: "${WATCHTOWER_NOTIFICATIONS}"
|
||||
WATCHTOWER_NOTIFICATION_URL: "${WATCHTOWER_NOTIFICATION_URL}"
|
||||
HOME: "/root"
|
||||
DOCKER_API_VERSION: "1.44"
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
# - VALKEY_ADDR → unset (not exposed publicly)
|
||||
# - RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true
|
||||
# - Redis service for Asynq task queue (local to homelab, exposed to prod via Caddy TCP proxy)
|
||||
# - LibreTranslate service for machine translation (internal network only)
|
||||
|
||||
services:
|
||||
redis:
|
||||
@@ -29,6 +30,26 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
libretranslate:
|
||||
image: libretranslate/libretranslate:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LT_API_KEYS: "true"
|
||||
LT_API_KEYS_DB_PATH: "/app/db/api_keys.db"
|
||||
# Limit to source→target pairs the runner actually uses
|
||||
LT_LOAD_ONLY: "en,ru,id,pt,fr"
|
||||
LT_DISABLE_WEB_UI: "true"
|
||||
LT_UPDATE_MODELS: "false"
|
||||
volumes:
|
||||
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
|
||||
- libretranslate_db:/app/db
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:5000/languages || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 120s
|
||||
|
||||
runner:
|
||||
image: kalekber/libnovel-runner:latest
|
||||
restart: unless-stopped
|
||||
@@ -36,6 +57,8 @@ services:
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
libretranslate:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# ── PocketBase ──────────────────────────────────────────────────────────
|
||||
POCKETBASE_URL: "https://pb.libnovel.cc"
|
||||
@@ -64,6 +87,10 @@ services:
|
||||
# ── Pocket TTS ──────────────────────────────────────────────────────────
|
||||
POCKET_TTS_URL: "${POCKET_TTS_URL}"
|
||||
|
||||
# ── LibreTranslate (internal Docker network) ────────────────────────────
|
||||
LIBRETRANSLATE_URL: "http://libretranslate:5000"
|
||||
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
|
||||
|
||||
# ── Asynq / Redis (local service) ───────────────────────────────────────
|
||||
# The runner connects to the local Redis sidecar.
|
||||
REDIS_ADDR: "redis:6379"
|
||||
@@ -74,6 +101,7 @@ services:
|
||||
RUNNER_POLL_INTERVAL: "${RUNNER_POLL_INTERVAL}"
|
||||
RUNNER_MAX_CONCURRENT_SCRAPE: "${RUNNER_MAX_CONCURRENT_SCRAPE}"
|
||||
RUNNER_MAX_CONCURRENT_AUDIO: "${RUNNER_MAX_CONCURRENT_AUDIO}"
|
||||
RUNNER_MAX_CONCURRENT_TRANSLATION: "${RUNNER_MAX_CONCURRENT_TRANSLATION}"
|
||||
RUNNER_TIMEOUT: "${RUNNER_TIMEOUT}"
|
||||
RUNNER_METRICS_ADDR: "${RUNNER_METRICS_ADDR}"
|
||||
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
|
||||
@@ -90,3 +118,5 @@ services:
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
libretranslate_models:
|
||||
libretranslate_db:
|
||||
|
||||
@@ -245,6 +245,20 @@ create "comment_votes" '{
|
||||
{"name":"vote", "type":"text"}
|
||||
]}'
|
||||
|
||||
create "translation_jobs" '{
|
||||
"name":"translation_jobs","type":"base","fields":[
|
||||
{"name":"cache_key", "type":"text", "required":true},
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"chapter", "type":"number","required":true},
|
||||
{"name":"lang", "type":"text", "required":true},
|
||||
{"name":"worker_id", "type":"text"},
|
||||
{"name":"status", "type":"text", "required":true},
|
||||
{"name":"error_message","type":"text"},
|
||||
{"name":"started", "type":"date"},
|
||||
{"name":"finished", "type":"date"},
|
||||
{"name":"heartbeat_at", "type":"date"}
|
||||
]}'
|
||||
|
||||
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
|
||||
add_field "scraping_tasks" "heartbeat_at" "date"
|
||||
add_field "audio_jobs" "heartbeat_at" "date"
|
||||
@@ -258,5 +272,7 @@ add_field "app_users" "verification_token" "text"
|
||||
add_field "app_users" "verification_token_exp" "text"
|
||||
add_field "app_users" "oauth_provider" "text"
|
||||
add_field "app_users" "oauth_id" "text"
|
||||
add_field "app_users" "polar_customer_id" "text"
|
||||
add_field "app_users" "polar_subscription_id" "text"
|
||||
|
||||
log "done"
|
||||
|
||||
3
ui/.gitignore
vendored
3
ui/.gitignore
vendored
@@ -21,3 +21,6 @@ Thumbs.db
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Generated by CI at build time — do not commit
|
||||
/static/releases.json
|
||||
|
||||
@@ -14,10 +14,12 @@ COPY . .
|
||||
# Build-time version info — injected by docker-compose or CI via --build-arg.
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG BUILD_COMMIT=unknown
|
||||
ARG BUILD_TIME=unknown
|
||||
|
||||
# Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them.
|
||||
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
|
||||
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
|
||||
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
|
||||
|
||||
RUN npm run build
|
||||
|
||||
@@ -40,5 +42,16 @@ ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOST=0.0.0.0
|
||||
|
||||
# Carry build-time metadata into the runtime image so the UI footer can
|
||||
# display the version, commit SHA, and build timestamp.
|
||||
# These must be re-declared after the second FROM — ARG values do not
|
||||
# cross stage boundaries, but ENV values set here persist at runtime.
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG BUILD_COMMIT=unknown
|
||||
ARG BUILD_TIME=unknown
|
||||
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
|
||||
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
|
||||
ENV PUBLIC_BUILD_TIME=$BUILD_TIME
|
||||
|
||||
EXPOSE $PORT
|
||||
CMD ["node", "build"]
|
||||
|
||||
413
ui/messages/en.json
Normal file
413
ui/messages/en.json
Normal file
@@ -0,0 +1,413 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"nav_library": "Library",
|
||||
"nav_catalogue": "Catalogue",
|
||||
"nav_feedback": "Feedback",
|
||||
"nav_admin": "Admin",
|
||||
"nav_profile": "Profile",
|
||||
"nav_sign_in": "Sign in",
|
||||
"nav_sign_out": "Sign out",
|
||||
"nav_toggle_menu": "Toggle menu",
|
||||
"nav_admin_panel": "Admin panel",
|
||||
|
||||
"footer_library": "Library",
|
||||
"footer_catalogue": "Catalogue",
|
||||
"footer_feedback": "Feedback",
|
||||
"footer_disclaimer": "Disclaimer",
|
||||
"footer_privacy": "Privacy",
|
||||
"footer_dmca": "DMCA",
|
||||
"footer_copyright": "© {year} libnovel",
|
||||
"footer_dev": "dev",
|
||||
|
||||
"home_title": "libnovel",
|
||||
"home_stat_books": "Books",
|
||||
"home_stat_chapters": "Chapters",
|
||||
"home_stat_in_progress": "In progress",
|
||||
"home_continue_reading": "Continue Reading",
|
||||
"home_view_all": "View all",
|
||||
"home_recently_updated": "Recently Updated",
|
||||
"home_from_following": "From People You Follow",
|
||||
"home_empty_title": "Your library is empty",
|
||||
"home_empty_body": "Discover novels and scrape them into your library.",
|
||||
"home_discover_novels": "Discover Novels",
|
||||
"home_via_reader": "via {username}",
|
||||
"home_chapter_badge": "ch.{n}",
|
||||
|
||||
"player_generating": "Generating… {percent}%",
|
||||
"player_loading": "Loading…",
|
||||
"player_chapters": "Chapters",
|
||||
"player_chapter_n": "Chapter {n}",
|
||||
"player_toggle_chapter_list": "Toggle chapter list",
|
||||
"player_chapter_list_label": "Chapter list",
|
||||
"player_close_chapter_list": "Close chapter list",
|
||||
"player_rewind_15": "Rewind 15 seconds",
|
||||
"player_skip_30": "Skip 30 seconds",
|
||||
"player_back_15": "Back 15s",
|
||||
"player_forward_30": "Forward 30s",
|
||||
"player_play": "Play",
|
||||
"player_pause": "Pause",
|
||||
"player_speed_label": "Playback speed {speed}x",
|
||||
"player_seek_label": "Chapter progress",
|
||||
"player_change_speed": "Change playback speed",
|
||||
"player_auto_next_on": "Auto-next on",
|
||||
"player_auto_next_off": "Auto-next off",
|
||||
"player_auto_next_ready": "Auto-next on — Ch.{n} ready",
|
||||
"player_auto_next_preparing": "Auto-next on — preparing Ch.{n}…",
|
||||
"player_auto_next_aria": "Auto-next {state}",
|
||||
"player_go_to_chapter": "Go to chapter",
|
||||
"player_close": "Close player",
|
||||
|
||||
"login_page_title": "Sign in — libnovel",
|
||||
"login_heading": "Sign in to libnovel",
|
||||
"login_subheading": "Choose a provider to continue",
|
||||
"login_continue_google": "Continue with Google",
|
||||
"login_continue_github": "Continue with GitHub",
|
||||
"login_terms_notice": "By signing in you agree to our terms of service.",
|
||||
"login_error_oauth_state": "Sign-in was cancelled or expired. Please try again.",
|
||||
"login_error_oauth_failed": "Could not connect to the provider. Please try again.",
|
||||
"login_error_oauth_no_email": "Your account has no verified email address. Please add one and retry.",
|
||||
|
||||
"books_page_title": "Library — libnovel",
|
||||
"books_heading": "Your Library",
|
||||
"books_empty_title": "No books yet",
|
||||
"books_empty_body": "Add books to your library by visiting a book page.",
|
||||
"books_browse_catalogue": "Browse Catalogue",
|
||||
"books_chapter_count": "{n} chapters",
|
||||
"books_last_read": "Last read: Ch.{n}",
|
||||
"books_reading_progress": "Ch.{current} / {total}",
|
||||
"books_remove": "Remove",
|
||||
|
||||
"catalogue_page_title": "Catalogue — libnovel",
|
||||
"catalogue_heading": "Catalogue",
|
||||
"catalogue_search_placeholder": "Search novels…",
|
||||
"catalogue_filter_genre": "Genre",
|
||||
"catalogue_filter_status": "Status",
|
||||
"catalogue_filter_sort": "Sort",
|
||||
"catalogue_sort_popular": "Popular",
|
||||
"catalogue_sort_new": "New",
|
||||
"catalogue_sort_top_rated": "Top Rated",
|
||||
"catalogue_sort_rank": "Rank",
|
||||
"catalogue_status_all": "All",
|
||||
"catalogue_status_ongoing": "Ongoing",
|
||||
"catalogue_status_completed": "Completed",
|
||||
"catalogue_genre_all": "All genres",
|
||||
"catalogue_clear_filters": "Clear",
|
||||
"catalogue_reset": "Reset",
|
||||
"catalogue_no_results": "No novels found.",
|
||||
"catalogue_loading": "Loading…",
|
||||
"catalogue_load_more": "Load more",
|
||||
"catalogue_results_count": "{n} results",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_signin_to_save": "Sign in to save",
|
||||
"book_detail_add_to_library": "Add to Library",
|
||||
"book_detail_remove_from_library": "Remove from Library",
|
||||
"book_detail_read_now": "Read Now",
|
||||
"book_detail_continue_reading": "Continue Reading",
|
||||
"book_detail_start_reading": "Start Reading",
|
||||
"book_detail_chapters": "{n} Chapters",
|
||||
"book_detail_status": "Status",
|
||||
"book_detail_author": "Author",
|
||||
"book_detail_genres": "Genres",
|
||||
"book_detail_description": "Description",
|
||||
"book_detail_source": "Source",
|
||||
"book_detail_rescrape": "Re-scrape",
|
||||
"book_detail_scraping": "Scraping…",
|
||||
"book_detail_in_library": "In Library",
|
||||
|
||||
"chapters_page_title": "Chapters — {title}",
|
||||
"chapters_heading": "Chapters",
|
||||
"chapters_back_to_book": "Back to book",
|
||||
"chapters_reading_now": "Reading",
|
||||
"chapters_empty": "No chapters scraped yet.",
|
||||
|
||||
"reader_page_title": "{title} — Ch.{n} — libnovel",
|
||||
"reader_play_narration": "Play narration",
|
||||
"reader_generating_audio": "Generating audio…",
|
||||
"reader_signin_for_audio": "Audio narration available",
|
||||
"reader_signin_audio_desc": "Sign in to listen to this chapter narrated by AI.",
|
||||
"reader_audio_error": "Audio generation failed.",
|
||||
"reader_prev_chapter": "Previous chapter",
|
||||
"reader_next_chapter": "Next chapter",
|
||||
"reader_back_to_chapters": "Back to chapters",
|
||||
"reader_chapter_n": "Chapter {n}",
|
||||
"reader_change_voice": "Change voice",
|
||||
"reader_voice_panel_title": "Select voice",
|
||||
"reader_voice_kokoro": "Kokoro voices",
|
||||
"reader_voice_pocket": "Pocket-TTS voices",
|
||||
"reader_voice_play_sample": "Play sample",
|
||||
"reader_voice_stop_sample": "Stop sample",
|
||||
"reader_voice_selected": "Selected",
|
||||
"reader_close_voice_panel": "Close voice panel",
|
||||
"reader_auto_next": "Auto-next",
|
||||
"reader_speed": "Speed",
|
||||
"reader_preview_notice": "Preview — this chapter has not been fully scraped.",
|
||||
|
||||
"profile_page_title": "Profile — libnovel",
|
||||
"profile_heading": "Profile",
|
||||
"profile_avatar_label": "Avatar",
|
||||
"profile_change_avatar": "Change avatar",
|
||||
"profile_username": "Username",
|
||||
"profile_email": "Email",
|
||||
"profile_change_password": "Change password",
|
||||
"profile_current_password": "Current password",
|
||||
"profile_new_password": "New password",
|
||||
"profile_confirm_password": "Confirm password",
|
||||
"profile_save_password": "Save password",
|
||||
"profile_appearance_heading": "Appearance",
|
||||
"profile_theme_label": "Theme",
|
||||
"profile_theme_amber": "Amber",
|
||||
"profile_theme_slate": "Slate",
|
||||
"profile_theme_rose": "Rose",
|
||||
"profile_theme_light": "Light",
|
||||
"profile_theme_light_slate": "Light Blue",
|
||||
"profile_theme_light_rose": "Light Rose",
|
||||
"profile_reading_heading": "Reading settings",
|
||||
"profile_voice_label": "Default voice",
|
||||
"profile_speed_label": "Playback speed",
|
||||
"profile_auto_next_label": "Auto-next chapter",
|
||||
"profile_save_settings": "Save settings",
|
||||
"profile_settings_saved": "Settings saved.",
|
||||
"profile_settings_error": "Failed to save settings.",
|
||||
"profile_password_saved": "Password changed.",
|
||||
"profile_password_error": "Failed to change password.",
|
||||
"profile_sessions_heading": "Active sessions",
|
||||
"profile_sign_out_all": "Sign out all other devices",
|
||||
"profile_joined": "Joined {date}",
|
||||
|
||||
"user_page_title": "{username} — libnovel",
|
||||
"user_library_heading": "{username}'s Library",
|
||||
"user_follow": "Follow",
|
||||
"user_unfollow": "Unfollow",
|
||||
"user_followers": "{n} followers",
|
||||
"user_following": "{n} following",
|
||||
"user_library_empty": "No books in library.",
|
||||
|
||||
"error_not_found_title": "Page not found",
|
||||
"error_not_found_body": "The page you're looking for doesn't exist.",
|
||||
"error_generic_title": "Something went wrong",
|
||||
"error_go_home": "Go home",
|
||||
"error_status": "Error {status}",
|
||||
|
||||
"admin_scrape_page_title": "Scrape — Admin",
|
||||
"admin_scrape_heading": "Scrape",
|
||||
"admin_scrape_catalogue": "Scrape Catalogue",
|
||||
"admin_scrape_book": "Scrape Book",
|
||||
"admin_scrape_url_placeholder": "novelfire.net book URL",
|
||||
"admin_scrape_range": "Chapter range",
|
||||
"admin_scrape_from": "From",
|
||||
"admin_scrape_to": "To",
|
||||
"admin_scrape_submit": "Scrape",
|
||||
"admin_scrape_cancel": "Cancel",
|
||||
"admin_scrape_status_pending": "Pending",
|
||||
"admin_scrape_status_running": "Running",
|
||||
"admin_scrape_status_done": "Done",
|
||||
"admin_scrape_status_failed": "Failed",
|
||||
"admin_scrape_status_cancelled": "Cancelled",
|
||||
"admin_tasks_heading": "Recent tasks",
|
||||
"admin_tasks_empty": "No tasks yet.",
|
||||
|
||||
"admin_audio_page_title": "Audio — Admin",
|
||||
"admin_audio_heading": "Audio Jobs",
|
||||
"admin_audio_empty": "No audio jobs.",
|
||||
|
||||
"admin_changelog_page_title": "Changelog — Admin",
|
||||
"admin_changelog_heading": "Changelog",
|
||||
|
||||
"comments_heading": "Comments",
|
||||
"comments_empty": "No comments yet. Be the first!",
|
||||
"comments_placeholder": "Write a comment…",
|
||||
"comments_submit": "Post",
|
||||
"comments_login_prompt": "Sign in to comment.",
|
||||
"comments_vote_up": "Upvote",
|
||||
"comments_vote_down": "Downvote",
|
||||
"comments_delete": "Delete",
|
||||
"comments_reply": "Reply",
|
||||
"comments_show_replies": "Show {n} replies",
|
||||
"comments_hide_replies": "Hide replies",
|
||||
"comments_edited": "edited",
|
||||
"comments_deleted": "[deleted]",
|
||||
|
||||
"disclaimer_page_title": "Disclaimer — libnovel",
|
||||
"privacy_page_title": "Privacy Policy — libnovel",
|
||||
"dmca_page_title": "DMCA — libnovel",
|
||||
"terms_page_title": "Terms of Service — libnovel",
|
||||
|
||||
"common_loading": "Loading…",
|
||||
"common_error": "Error",
|
||||
"common_save": "Save",
|
||||
"common_cancel": "Cancel",
|
||||
"common_close": "Close",
|
||||
"common_search": "Search",
|
||||
"common_back": "Back",
|
||||
"common_next": "Next",
|
||||
"common_previous": "Previous",
|
||||
"common_yes": "Yes",
|
||||
"common_no": "No",
|
||||
"common_on": "on",
|
||||
"common_off": "off",
|
||||
|
||||
"locale_switcher_label": "Language",
|
||||
|
||||
"books_empty_library": "Your library is empty.",
|
||||
"books_empty_discover": "Books you start reading or save from",
|
||||
"books_empty_discover_link": "Discover",
|
||||
"books_empty_discover_suffix": "will appear here.",
|
||||
"books_count": "{n} book{s}",
|
||||
|
||||
"catalogue_sort_updated": "Updated",
|
||||
"catalogue_search_button": "Search",
|
||||
"catalogue_refresh": "Refresh",
|
||||
"catalogue_refreshing": "Queuing\u2026",
|
||||
"catalogue_refresh_mobile": "Refresh catalogue",
|
||||
"catalogue_all_loaded": "All novels loaded",
|
||||
"catalogue_scroll_top": "Back to top",
|
||||
"catalogue_view_grid": "Grid view",
|
||||
"catalogue_view_list": "List view",
|
||||
"catalogue_browse_source": "Browse novels from novelfire.net",
|
||||
"catalogue_search_results": "{n} result{s} for \"{q}\"",
|
||||
"catalogue_search_local_count": "({local} local, {remote} from novelfire)",
|
||||
"catalogue_rank_ranked": "{n} novels ranked from last catalogue scrape",
|
||||
"catalogue_rank_no_data": "No ranking data.",
|
||||
"catalogue_rank_no_data_body": "No ranking data \u2014 run a full catalogue scrape to populate",
|
||||
"catalogue_rank_run_scrape_admin": "Click Refresh catalogue above to trigger a full catalogue scrape.",
|
||||
"catalogue_rank_run_scrape_user": "Ask an admin to run a catalogue scrape.",
|
||||
"catalogue_scrape_queued_flash": "Full catalogue scrape queued. Library and ranking will update as books are processed.",
|
||||
"catalogue_scrape_busy_flash": "A scrape job is already running. Check back once it finishes.",
|
||||
"catalogue_scrape_error_flash": "Failed to queue scrape. Check that the scraper service is reachable.",
|
||||
"catalogue_filters_label": "Filters",
|
||||
"catalogue_apply": "Apply",
|
||||
"catalogue_filter_rank_note": "Genre & status filters apply to Browse only",
|
||||
"catalogue_no_results_search": "No results found.",
|
||||
"catalogue_no_results_try": "Try a different search term.",
|
||||
"catalogue_no_results_filters": "Try different filters or check back later.",
|
||||
"catalogue_scrape_queued_badge": "Queued",
|
||||
"catalogue_scrape_busy_badge": "Scraper busy",
|
||||
"catalogue_scrape_busy_list": "Busy",
|
||||
"catalogue_scrape_forbidden_badge": "Forbidden",
|
||||
"catalogue_scrape_novel_button": "Scrape",
|
||||
"catalogue_scraping_novel": "Scraping\u2026",
|
||||
|
||||
"book_detail_not_in_library": "not in library",
|
||||
"book_detail_continue_ch": "Continue ch.{n}",
|
||||
"book_detail_start_ch1": "Start from ch.1",
|
||||
"book_detail_preview_ch1": "Preview ch.1",
|
||||
"book_detail_reading_ch": "Reading ch.{n} of {total}",
|
||||
"book_detail_n_chapters": "{n} chapters",
|
||||
"book_detail_rescraping": "Queuing\u2026",
|
||||
"book_detail_from_chapter": "From chapter",
|
||||
"book_detail_to_chapter": "To chapter (optional)",
|
||||
"book_detail_range_queuing": "Queuing\u2026",
|
||||
"book_detail_scrape_range": "Scrape range",
|
||||
"book_detail_admin": "Admin",
|
||||
"book_detail_scraping_progress": "Fetching the first 20 chapters. This page will refresh automatically.",
|
||||
"book_detail_scraping_home": "\u2190 Home",
|
||||
"book_detail_rescrape_book": "Rescrape book",
|
||||
"book_detail_less": "Less",
|
||||
"book_detail_more": "More",
|
||||
|
||||
"chapters_search_placeholder": "Search chapters\u2026",
|
||||
"chapters_jump_to": "Jump to Ch.{n}",
|
||||
"chapters_no_match": "No chapters match \"{q}\"",
|
||||
"chapters_none_available": "No chapters available yet.",
|
||||
"chapters_reading_indicator": "reading",
|
||||
"chapters_result_count": "{n} results",
|
||||
|
||||
"reader_fetching_chapter": "Fetching chapter\u2026",
|
||||
"reader_words": "{n} words",
|
||||
"reader_preview_audio_notice": "Preview chapter \u2014 audio not available for books outside the library.",
|
||||
|
||||
"profile_click_to_change": "Click avatar to change photo",
|
||||
"profile_tts_voice": "TTS voice",
|
||||
"profile_auto_advance": "Auto-advance to next chapter",
|
||||
"profile_saving": "Saving\u2026",
|
||||
"profile_saved": "Saved!",
|
||||
"profile_session_this": "This session",
|
||||
"profile_session_signed_in": "Signed in {date}",
|
||||
"profile_session_last_seen": "\u00b7 Last seen {date}",
|
||||
"profile_session_sign_out": "Sign out",
|
||||
"profile_session_end": "End",
|
||||
"profile_session_unrecognised": "These are all devices currently signed into your account. End any session you don\u2019t recognise.",
|
||||
"profile_no_sessions": "No session records found. Sessions are tracked from the next login.",
|
||||
"profile_change_password_heading": "Change password",
|
||||
"profile_update_password": "Update password",
|
||||
"profile_updating": "Updating\u2026",
|
||||
"profile_password_changed_ok": "Password changed successfully.",
|
||||
"profile_playback_speed": "Playback speed \u2014 {speed}x",
|
||||
|
||||
"profile_subscription_heading": "Subscription",
|
||||
"profile_plan_pro": "Pro",
|
||||
"profile_plan_free": "Free",
|
||||
"profile_pro_active": "Your Pro subscription is active.",
|
||||
"profile_pro_perks": "Unlimited audio, all translation languages, and voice selection are enabled.",
|
||||
"profile_manage_subscription": "Manage subscription",
|
||||
"profile_upgrade_heading": "Upgrade to Pro",
|
||||
"profile_upgrade_desc": "Unlock unlimited audio, translations in 4 languages, and voice selection.",
|
||||
"profile_upgrade_monthly": "Monthly \u2014 $6 / mo",
|
||||
"profile_upgrade_annual": "Annual \u2014 $48 / yr",
|
||||
"profile_free_limits": "Free plan: 3 audio chapters per day, English reading only.",
|
||||
|
||||
"user_currently_reading": "Currently Reading",
|
||||
"user_library_count": "Library ({n})",
|
||||
"user_joined": "Joined {date}",
|
||||
"user_followers_label": "followers",
|
||||
"user_following_label": "following",
|
||||
"user_no_books": "No books in library yet.",
|
||||
|
||||
"admin_pages_label": "Pages",
|
||||
"admin_tools_label": "Tools",
|
||||
|
||||
"admin_scrape_status_idle": "Idle",
|
||||
"admin_scrape_status_running": "Running",
|
||||
"admin_scrape_full_catalogue": "Full catalogue",
|
||||
"admin_scrape_single_book": "Single book",
|
||||
"admin_scrape_quick_genres": "Quick genres",
|
||||
"admin_scrape_task_history": "Task history",
|
||||
"admin_scrape_filter_placeholder": "Filter by kind, status or URL\u2026",
|
||||
"admin_scrape_no_matching": "No matching tasks.",
|
||||
"admin_scrape_start": "Start scrape",
|
||||
"admin_scrape_queuing": "Queuing\u2026",
|
||||
"admin_scrape_running": "Running\u2026",
|
||||
|
||||
"admin_audio_filter_jobs": "Filter by slug, voice or status\u2026",
|
||||
"admin_audio_filter_cache": "Filter by slug, chapter or voice\u2026",
|
||||
"admin_audio_no_matching_jobs": "No matching jobs.",
|
||||
"admin_audio_no_jobs": "No audio jobs yet.",
|
||||
"admin_audio_cache_empty": "Audio cache is empty.",
|
||||
"admin_audio_no_cache_results": "No results.",
|
||||
|
||||
"admin_changelog_gitea": "Gitea releases",
|
||||
"admin_changelog_no_releases": "No releases found.",
|
||||
"admin_changelog_load_error": "Could not load releases: {error}",
|
||||
|
||||
"comments_top": "Top",
|
||||
"comments_new": "New",
|
||||
"comments_posting": "Posting\u2026",
|
||||
"comments_login_link": "Log in",
|
||||
"comments_login_suffix": "to leave a comment.",
|
||||
"comments_anonymous": "Anonymous",
|
||||
|
||||
"reader_audio_narration": "Audio Narration",
|
||||
"reader_playing": "Playing \u2014 controls below",
|
||||
"reader_paused": "Paused \u2014 controls below",
|
||||
"reader_ch_ready": "Ch.{n} ready",
|
||||
"reader_ch_preparing": "Preparing Ch.{n}\u2026 {percent}%",
|
||||
"reader_ch_generate_on_nav": "Ch.{n} will generate on navigate",
|
||||
"reader_now_playing": "Now playing: {title}",
|
||||
"reader_load_this_chapter": "Load this chapter",
|
||||
"reader_generate_samples": "Generate missing samples",
|
||||
"reader_voice_applies_next": "New voice applies on next \u201cPlay narration\u201d.",
|
||||
"reader_choose_voice": "Choose Voice",
|
||||
"reader_generating_narration": "Generating narration\u2026",
|
||||
|
||||
"profile_font_family": "Font Family",
|
||||
"profile_font_system": "System",
|
||||
"profile_font_serif": "Serif",
|
||||
"profile_font_mono": "Monospace",
|
||||
"profile_text_size": "Text Size",
|
||||
"profile_text_size_sm": "Small",
|
||||
"profile_text_size_md": "Normal",
|
||||
"profile_text_size_lg": "Large",
|
||||
"profile_text_size_xl": "X-Large"
|
||||
}
|
||||
412
ui/messages/fr.json
Normal file
412
ui/messages/fr.json
Normal file
@@ -0,0 +1,412 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"nav_library": "Bibliothèque",
|
||||
"nav_catalogue": "Catalogue",
|
||||
"nav_feedback": "Retour",
|
||||
"nav_admin": "Admin",
|
||||
"nav_profile": "Profil",
|
||||
"nav_sign_in": "Connexion",
|
||||
"nav_sign_out": "Déconnexion",
|
||||
"nav_toggle_menu": "Menu",
|
||||
"nav_admin_panel": "Panneau admin",
|
||||
|
||||
"footer_library": "Bibliothèque",
|
||||
"footer_catalogue": "Catalogue",
|
||||
"footer_feedback": "Retour",
|
||||
"footer_disclaimer": "Avertissement",
|
||||
"footer_privacy": "Confidentialité",
|
||||
"footer_dmca": "DMCA",
|
||||
"footer_copyright": "© {year} libnovel",
|
||||
"footer_dev": "dev",
|
||||
|
||||
"home_title": "libnovel",
|
||||
"home_stat_books": "Livres",
|
||||
"home_stat_chapters": "Chapitres",
|
||||
"home_stat_in_progress": "En cours",
|
||||
"home_continue_reading": "Continuer la lecture",
|
||||
"home_view_all": "Voir tout",
|
||||
"home_recently_updated": "Récemment mis à jour",
|
||||
"home_from_following": "Des personnes que vous suivez",
|
||||
"home_empty_title": "Votre bibliothèque est vide",
|
||||
"home_empty_body": "Découvrez des romans et ajoutez-les à votre bibliothèque.",
|
||||
"home_discover_novels": "Découvrir des romans",
|
||||
"home_via_reader": "via {username}",
|
||||
"home_chapter_badge": "ch.{n}",
|
||||
|
||||
"player_generating": "Génération… {percent}%",
|
||||
"player_loading": "Chargement…",
|
||||
"player_chapters": "Chapitres",
|
||||
"player_chapter_n": "Chapitre {n}",
|
||||
"player_toggle_chapter_list": "Liste des chapitres",
|
||||
"player_chapter_list_label": "Liste des chapitres",
|
||||
"player_close_chapter_list": "Fermer la liste des chapitres",
|
||||
"player_rewind_15": "Reculer de 15 secondes",
|
||||
"player_skip_30": "Avancer de 30 secondes",
|
||||
"player_back_15": "−15 s",
|
||||
"player_forward_30": "+30 s",
|
||||
"player_play": "Lecture",
|
||||
"player_pause": "Pause",
|
||||
"player_speed_label": "Vitesse {speed}x",
|
||||
"player_seek_label": "Progression du chapitre",
|
||||
"player_change_speed": "Changer la vitesse",
|
||||
"player_auto_next_on": "Suivant auto activé",
|
||||
"player_auto_next_off": "Suivant auto désactivé",
|
||||
"player_auto_next_ready": "Suivant auto — Ch.{n} prêt",
|
||||
"player_auto_next_preparing": "Suivant auto — préparation Ch.{n}…",
|
||||
"player_auto_next_aria": "Suivant auto {state}",
|
||||
"player_go_to_chapter": "Aller au chapitre",
|
||||
"player_close": "Fermer le lecteur",
|
||||
|
||||
"login_page_title": "Connexion — libnovel",
|
||||
"login_heading": "Se connecter à libnovel",
|
||||
"login_subheading": "Choisissez un fournisseur pour continuer",
|
||||
"login_continue_google": "Continuer avec Google",
|
||||
"login_continue_github": "Continuer avec GitHub",
|
||||
"login_terms_notice": "En vous connectant, vous acceptez nos conditions d'utilisation.",
|
||||
"login_error_oauth_state": "Connexion annulée ou expirée. Veuillez réessayer.",
|
||||
"login_error_oauth_failed": "Impossible de se connecter au fournisseur. Veuillez réessayer.",
|
||||
"login_error_oauth_no_email": "Votre compte n'a pas d'adresse e-mail vérifiée. Ajoutez-en une et réessayez.",
|
||||
|
||||
"books_page_title": "Bibliothèque — libnovel",
|
||||
"books_heading": "Votre bibliothèque",
|
||||
"books_empty_title": "Aucun livre pour l'instant",
|
||||
"books_empty_body": "Ajoutez des livres à votre bibliothèque en visitant une page de livre.",
|
||||
"books_browse_catalogue": "Parcourir le catalogue",
|
||||
"books_chapter_count": "{n} chapitres",
|
||||
"books_last_read": "Dernier lu : Ch.{n}",
|
||||
"books_reading_progress": "Ch.{current} / {total}",
|
||||
"books_remove": "Supprimer",
|
||||
|
||||
"catalogue_page_title": "Catalogue — libnovel",
|
||||
"catalogue_heading": "Catalogue",
|
||||
"catalogue_search_placeholder": "Rechercher des romans…",
|
||||
"catalogue_filter_genre": "Genre",
|
||||
"catalogue_filter_status": "Statut",
|
||||
"catalogue_filter_sort": "Trier",
|
||||
"catalogue_sort_popular": "Populaire",
|
||||
"catalogue_sort_new": "Nouveau",
|
||||
"catalogue_sort_top_rated": "Mieux notés",
|
||||
"catalogue_sort_rank": "Rang",
|
||||
"catalogue_status_all": "Tous",
|
||||
"catalogue_status_ongoing": "En cours",
|
||||
"catalogue_status_completed": "Terminé",
|
||||
"catalogue_genre_all": "Tous les genres",
|
||||
"catalogue_clear_filters": "Effacer",
|
||||
"catalogue_reset": "Réinitialiser",
|
||||
"catalogue_no_results": "Aucun roman trouvé.",
|
||||
"catalogue_loading": "Chargement…",
|
||||
"catalogue_load_more": "Charger plus",
|
||||
"catalogue_results_count": "{n} résultats",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_signin_to_save": "Connectez-vous pour sauvegarder",
|
||||
"book_detail_add_to_library": "Ajouter à la bibliothèque",
|
||||
"book_detail_remove_from_library": "Retirer de la bibliothèque",
|
||||
"book_detail_read_now": "Lire maintenant",
|
||||
"book_detail_continue_reading": "Continuer la lecture",
|
||||
"book_detail_start_reading": "Commencer la lecture",
|
||||
"book_detail_chapters": "{n} chapitres",
|
||||
"book_detail_status": "Statut",
|
||||
"book_detail_author": "Auteur",
|
||||
"book_detail_genres": "Genres",
|
||||
"book_detail_description": "Description",
|
||||
"book_detail_source": "Source",
|
||||
"book_detail_rescrape": "Réextraire",
|
||||
"book_detail_scraping": "Extraction en cours…",
|
||||
"book_detail_in_library": "Dans la bibliothèque",
|
||||
|
||||
"chapters_page_title": "Chapitres — {title}",
|
||||
"chapters_heading": "Chapitres",
|
||||
"chapters_back_to_book": "Retour au livre",
|
||||
"chapters_reading_now": "En cours de lecture",
|
||||
"chapters_empty": "Aucun chapitre extrait pour l'instant.",
|
||||
|
||||
"reader_page_title": "{title} — Ch.{n} — libnovel",
|
||||
"reader_play_narration": "Lire la narration",
|
||||
"reader_generating_audio": "Génération audio…",
|
||||
"reader_signin_for_audio": "Narration audio disponible",
|
||||
"reader_signin_audio_desc": "Connectez-vous pour écouter ce chapitre narré par l'IA.",
|
||||
"reader_audio_error": "Échec de la génération audio.",
|
||||
"reader_prev_chapter": "Chapitre précédent",
|
||||
"reader_next_chapter": "Chapitre suivant",
|
||||
"reader_back_to_chapters": "Retour aux chapitres",
|
||||
"reader_chapter_n": "Chapitre {n}",
|
||||
"reader_change_voice": "Changer de voix",
|
||||
"reader_voice_panel_title": "Sélectionner une voix",
|
||||
"reader_voice_kokoro": "Voix Kokoro",
|
||||
"reader_voice_pocket": "Voix Pocket-TTS",
|
||||
"reader_voice_play_sample": "Écouter un extrait",
|
||||
"reader_voice_stop_sample": "Arrêter l'extrait",
|
||||
"reader_voice_selected": "Sélectionné",
|
||||
"reader_close_voice_panel": "Fermer le panneau vocal",
|
||||
"reader_auto_next": "Suivant auto",
|
||||
"reader_speed": "Vitesse",
|
||||
"reader_preview_notice": "Aperçu — ce chapitre n'a pas été entièrement extrait.",
|
||||
|
||||
"profile_page_title": "Profil — libnovel",
|
||||
"profile_heading": "Profil",
|
||||
"profile_avatar_label": "Avatar",
|
||||
"profile_change_avatar": "Changer l'avatar",
|
||||
"profile_username": "Nom d'utilisateur",
|
||||
"profile_email": "E-mail",
|
||||
"profile_change_password": "Changer le mot de passe",
|
||||
"profile_current_password": "Mot de passe actuel",
|
||||
"profile_new_password": "Nouveau mot de passe",
|
||||
"profile_confirm_password": "Confirmer le mot de passe",
|
||||
"profile_save_password": "Enregistrer le mot de passe",
|
||||
"profile_appearance_heading": "Apparence",
|
||||
"profile_theme_label": "Thème",
|
||||
"profile_theme_amber": "Ambre",
|
||||
"profile_theme_slate": "Ardoise",
|
||||
"profile_theme_rose": "Rose",
|
||||
"profile_theme_light": "Light",
|
||||
"profile_theme_light_slate": "Light Blue",
|
||||
"profile_theme_light_rose": "Light Rose",
|
||||
"profile_reading_heading": "Paramètres de lecture",
|
||||
"profile_voice_label": "Voix par défaut",
|
||||
"profile_speed_label": "Vitesse de lecture",
|
||||
"profile_auto_next_label": "Chapitre suivant automatique",
|
||||
"profile_save_settings": "Enregistrer les paramètres",
|
||||
"profile_settings_saved": "Paramètres enregistrés.",
|
||||
"profile_settings_error": "Impossible d'enregistrer les paramètres.",
|
||||
"profile_password_saved": "Mot de passe modifié.",
|
||||
"profile_password_error": "Impossible de modifier le mot de passe.",
|
||||
"profile_sessions_heading": "Sessions actives",
|
||||
"profile_sign_out_all": "Se déconnecter de tous les autres appareils",
|
||||
"profile_joined": "Inscrit le {date}",
|
||||
|
||||
"user_page_title": "{username} — libnovel",
|
||||
"user_library_heading": "Bibliothèque de {username}",
|
||||
"user_follow": "Suivre",
|
||||
"user_unfollow": "Ne plus suivre",
|
||||
"user_followers": "{n} abonnés",
|
||||
"user_following": "{n} abonnements",
|
||||
"user_library_empty": "Aucun livre dans la bibliothèque.",
|
||||
|
||||
"error_not_found_title": "Page introuvable",
|
||||
"error_not_found_body": "La page que vous cherchez n'existe pas.",
|
||||
"error_generic_title": "Une erreur s'est produite",
|
||||
"error_go_home": "Accueil",
|
||||
"error_status": "Erreur {status}",
|
||||
|
||||
"admin_scrape_page_title": "Extraction — Admin",
|
||||
"admin_scrape_heading": "Extraction",
|
||||
"admin_scrape_catalogue": "Extraire le catalogue",
|
||||
"admin_scrape_book": "Extraire un livre",
|
||||
"admin_scrape_url_placeholder": "URL du livre sur novelfire.net",
|
||||
"admin_scrape_range": "Plage de chapitres",
|
||||
"admin_scrape_from": "De",
|
||||
"admin_scrape_to": "À",
|
||||
"admin_scrape_submit": "Extraire",
|
||||
"admin_scrape_cancel": "Annuler",
|
||||
"admin_scrape_status_pending": "En attente",
|
||||
"admin_scrape_status_running": "En cours",
|
||||
"admin_scrape_status_done": "Terminé",
|
||||
"admin_scrape_status_failed": "Échoué",
|
||||
"admin_scrape_status_cancelled": "Annulé",
|
||||
"admin_tasks_heading": "Tâches récentes",
|
||||
"admin_tasks_empty": "Aucune tâche pour l'instant.",
|
||||
|
||||
"admin_audio_page_title": "Audio — Admin",
|
||||
"admin_audio_heading": "Tâches audio",
|
||||
"admin_audio_empty": "Aucune tâche audio.",
|
||||
|
||||
"admin_changelog_page_title": "Changelog — Admin",
|
||||
"admin_changelog_heading": "Changelog",
|
||||
|
||||
"comments_heading": "Commentaires",
|
||||
"comments_empty": "Aucun commentaire pour l'instant. Soyez le premier !",
|
||||
"comments_placeholder": "Écrire un commentaire…",
|
||||
"comments_submit": "Publier",
|
||||
"comments_login_prompt": "Connectez-vous pour commenter.",
|
||||
"comments_vote_up": "Vote positif",
|
||||
"comments_vote_down": "Vote négatif",
|
||||
"comments_delete": "Supprimer",
|
||||
"comments_reply": "Répondre",
|
||||
"comments_show_replies": "Afficher {n} réponses",
|
||||
"comments_hide_replies": "Masquer les réponses",
|
||||
"comments_edited": "modifié",
|
||||
"comments_deleted": "[supprimé]",
|
||||
|
||||
"disclaimer_page_title": "Avertissement — libnovel",
|
||||
"privacy_page_title": "Politique de confidentialité — libnovel",
|
||||
"dmca_page_title": "DMCA — libnovel",
|
||||
"terms_page_title": "Conditions d'utilisation — libnovel",
|
||||
|
||||
"common_loading": "Chargement…",
|
||||
"common_error": "Erreur",
|
||||
"common_save": "Enregistrer",
|
||||
"common_cancel": "Annuler",
|
||||
"common_close": "Fermer",
|
||||
"common_search": "Rechercher",
|
||||
"common_back": "Retour",
|
||||
"common_next": "Suivant",
|
||||
"common_previous": "Précédent",
|
||||
"common_yes": "Oui",
|
||||
"common_no": "Non",
|
||||
"common_on": "activé",
|
||||
"common_off": "désactivé",
|
||||
|
||||
"locale_switcher_label": "Langue",
|
||||
|
||||
"books_empty_library": "Votre bibliothèque est vide.",
|
||||
"books_empty_discover": "Les livres que vous commencez à lire ou enregistrez depuis",
|
||||
"books_empty_discover_link": "Découvrir",
|
||||
"books_empty_discover_suffix": "apparaîtront ici.",
|
||||
"books_count": "{n} livre{s}",
|
||||
|
||||
"catalogue_sort_updated": "Mis à jour",
|
||||
"catalogue_search_button": "Rechercher",
|
||||
"catalogue_refresh": "Actualiser",
|
||||
"catalogue_refreshing": "En file d'attente…",
|
||||
"catalogue_refresh_mobile": "Actualiser le catalogue",
|
||||
"catalogue_all_loaded": "Tous les romans chargés",
|
||||
"catalogue_scroll_top": "Retour en haut",
|
||||
"catalogue_view_grid": "Vue grille",
|
||||
"catalogue_view_list": "Vue liste",
|
||||
"catalogue_browse_source": "Parcourir les romans de novelfire.net",
|
||||
"catalogue_search_results": "{n} résultat{s} pour « {q} »",
|
||||
"catalogue_search_local_count": "({local} local, {remote} depuis novelfire)",
|
||||
"catalogue_rank_ranked": "{n} romans classés depuis le dernier scrape du catalogue",
|
||||
"catalogue_rank_no_data": "Aucune donnée de classement.",
|
||||
"catalogue_rank_no_data_body": "Aucune donnée de classement — lancez un scrape complet du catalogue pour remplir",
|
||||
"catalogue_rank_run_scrape_admin": "Cliquez sur Actualiser le catalogue ci-dessus pour déclencher un scrape complet.",
|
||||
"catalogue_rank_run_scrape_user": "Demandez à un administrateur d'effectuer un scrape du catalogue.",
|
||||
"catalogue_scrape_queued_flash": "Scrape complet du catalogue en file d'attente. La bibliothèque et le classement seront mis à jour au fur et à mesure du traitement des livres.",
|
||||
"catalogue_scrape_busy_flash": "Un job de scrape est déjà en cours. Revenez une fois terminé.",
|
||||
"catalogue_scrape_error_flash": "Échec de la mise en file d'attente du scrape. Vérifiez que le service de scraper est accessible.",
|
||||
"catalogue_filters_label": "Filtres",
|
||||
"catalogue_apply": "Appliquer",
|
||||
"catalogue_filter_rank_note": "Les filtres genre et statut s'appliquent uniquement à Parcourir",
|
||||
"catalogue_no_results_search": "Aucun résultat trouvé.",
|
||||
"catalogue_no_results_try": "Essayez un autre terme de recherche.",
|
||||
"catalogue_no_results_filters": "Essayez d'autres filtres ou revenez plus tard.",
|
||||
"catalogue_scrape_queued_badge": "En file",
|
||||
"catalogue_scrape_busy_badge": "Scraper occupé",
|
||||
"catalogue_scrape_busy_list": "Occupé",
|
||||
"catalogue_scrape_forbidden_badge": "Interdit",
|
||||
"catalogue_scrape_novel_button": "Extraire",
|
||||
"catalogue_scraping_novel": "Extraction…",
|
||||
|
||||
"book_detail_not_in_library": "pas dans la bibliothèque",
|
||||
"book_detail_continue_ch": "Continuer ch.{n}",
|
||||
"book_detail_start_ch1": "Commencer au ch.1",
|
||||
"book_detail_preview_ch1": "Aperçu ch.1",
|
||||
"book_detail_reading_ch": "Lecture ch.{n} sur {total}",
|
||||
"book_detail_n_chapters": "{n} chapitres",
|
||||
"book_detail_rescraping": "En file d'attente…",
|
||||
"book_detail_from_chapter": "À partir du chapitre",
|
||||
"book_detail_to_chapter": "Jusqu'au chapitre (optionnel)",
|
||||
"book_detail_range_queuing": "En file d'attente…",
|
||||
"book_detail_scrape_range": "Plage d'extraction",
|
||||
"book_detail_admin": "Admin",
|
||||
"book_detail_scraping_progress": "Récupération des 20 premiers chapitres. Cette page sera actualisée automatiquement.",
|
||||
"book_detail_scraping_home": "← Accueil",
|
||||
"book_detail_rescrape_book": "Réextraire le livre",
|
||||
"book_detail_less": "Moins",
|
||||
"book_detail_more": "Plus",
|
||||
|
||||
"chapters_search_placeholder": "Rechercher des chapitres…",
|
||||
"chapters_jump_to": "Aller au Ch.{n}",
|
||||
"chapters_no_match": "Aucun chapitre ne correspond à « {q} »",
|
||||
"chapters_none_available": "Aucun chapitre disponible pour l'instant.",
|
||||
"chapters_reading_indicator": "en cours",
|
||||
"chapters_result_count": "{n} résultats",
|
||||
|
||||
"reader_fetching_chapter": "Récupération du chapitre…",
|
||||
"reader_words": "{n} mots",
|
||||
"reader_preview_audio_notice": "Aperçu — audio non disponible pour les livres hors bibliothèque.",
|
||||
|
||||
"profile_click_to_change": "Cliquez sur l'avatar pour changer la photo",
|
||||
"profile_tts_voice": "Voix TTS",
|
||||
"profile_auto_advance": "Avancer automatiquement au chapitre suivant",
|
||||
"profile_saving": "Enregistrement…",
|
||||
"profile_saved": "Enregistré !",
|
||||
"profile_session_this": "Cette session",
|
||||
"profile_session_signed_in": "Connecté le {date}",
|
||||
"profile_session_last_seen": "· Dernière activité {date}",
|
||||
"profile_session_sign_out": "Se déconnecter",
|
||||
"profile_session_end": "Terminer",
|
||||
"profile_session_unrecognised": "Ce sont tous les appareils connectés à votre compte. Terminez toute session que vous ne reconnaissez pas.",
|
||||
"profile_no_sessions": "Aucun enregistrement de session trouvé. Les sessions sont suivies dès la prochaine connexion.",
|
||||
"profile_change_password_heading": "Changer le mot de passe",
|
||||
"profile_update_password": "Mettre à jour le mot de passe",
|
||||
"profile_updating": "Mise à jour…",
|
||||
"profile_password_changed_ok": "Mot de passe modifié avec succès.",
|
||||
"profile_playback_speed": "Vitesse de lecture — {speed}x",
|
||||
|
||||
"profile_subscription_heading": "Abonnement",
|
||||
"profile_plan_pro": "Pro",
|
||||
"profile_plan_free": "Gratuit",
|
||||
"profile_pro_active": "Votre abonnement Pro est actif.",
|
||||
"profile_pro_perks": "Audio illimité, toutes les langues de traduction et la sélection de voix sont activées.",
|
||||
"profile_manage_subscription": "Gérer l'abonnement",
|
||||
"profile_upgrade_heading": "Passer au Pro",
|
||||
"profile_upgrade_desc": "Débloquez l'audio illimité, les traductions en 4 langues et la sélection de voix.",
|
||||
"profile_upgrade_monthly": "Mensuel — 6 $ / mois",
|
||||
"profile_upgrade_annual": "Annuel — 48 $ / an",
|
||||
"profile_free_limits": "Plan gratuit : 3 chapitres audio par jour, lecture en anglais uniquement.",
|
||||
|
||||
"user_currently_reading": "En cours de lecture",
|
||||
"user_library_count": "Bibliothèque ({n})",
|
||||
"user_joined": "Inscrit le {date}",
|
||||
"user_followers_label": "abonnés",
|
||||
"user_following_label": "abonnements",
|
||||
"user_no_books": "Aucun livre dans la bibliothèque pour l'instant.",
|
||||
|
||||
"admin_pages_label": "Pages",
|
||||
"admin_tools_label": "Outils",
|
||||
|
||||
"admin_scrape_status_idle": "Inactif",
|
||||
"admin_scrape_full_catalogue": "Catalogue complet",
|
||||
"admin_scrape_single_book": "Livre unique",
|
||||
"admin_scrape_quick_genres": "Genres rapides",
|
||||
"admin_scrape_task_history": "Historique des tâches",
|
||||
"admin_scrape_filter_placeholder": "Filtrer par type, statut ou URL…",
|
||||
"admin_scrape_no_matching": "Aucune tâche correspondante.",
|
||||
"admin_scrape_start": "Démarrer l'extraction",
|
||||
"admin_scrape_queuing": "En file d'attente…",
|
||||
"admin_scrape_running": "En cours…",
|
||||
|
||||
"admin_audio_filter_jobs": "Filtrer par slug, voix ou statut…",
|
||||
"admin_audio_filter_cache": "Filtrer par slug, chapitre ou voix…",
|
||||
"admin_audio_no_matching_jobs": "Aucun job correspondant.",
|
||||
"admin_audio_no_jobs": "Aucun job audio pour l'instant.",
|
||||
"admin_audio_cache_empty": "Cache audio vide.",
|
||||
"admin_audio_no_cache_results": "Aucun résultat.",
|
||||
|
||||
"admin_changelog_gitea": "Releases Gitea",
|
||||
"admin_changelog_no_releases": "Aucune release trouvée.",
|
||||
"admin_changelog_load_error": "Impossible de charger les releases : {error}",
|
||||
|
||||
"comments_top": "Les meilleures",
|
||||
"comments_new": "Nouvelles",
|
||||
"comments_posting": "Publication…",
|
||||
"comments_login_link": "Connectez-vous",
|
||||
"comments_login_suffix": "pour laisser un commentaire.",
|
||||
"comments_anonymous": "Anonyme",
|
||||
|
||||
"reader_audio_narration": "Narration Audio",
|
||||
"reader_playing": "Lecture en cours — contrôles ci-dessous",
|
||||
"reader_paused": "En pause — contrôles ci-dessous",
|
||||
"reader_ch_ready": "Ch.{n} prêt",
|
||||
"reader_ch_preparing": "Préparation Ch.{n}… {percent}%",
|
||||
"reader_ch_generate_on_nav": "Ch.{n} sera généré lors de la navigation",
|
||||
"reader_now_playing": "En cours : {title}",
|
||||
"reader_load_this_chapter": "Charger ce chapitre",
|
||||
"reader_generate_samples": "Générer les échantillons manquants",
|
||||
"reader_voice_applies_next": "La nouvelle voix s'appliquera au prochain « Lire la narration ».",
|
||||
"reader_choose_voice": "Choisir une voix",
|
||||
"reader_generating_narration": "Génération de la narration…",
|
||||
|
||||
"profile_font_family": "Police",
|
||||
"profile_font_system": "Système",
|
||||
"profile_font_serif": "Serif",
|
||||
"profile_font_mono": "Mono",
|
||||
"profile_text_size": "Taille du texte",
|
||||
"profile_text_size_sm": "Petit",
|
||||
"profile_text_size_md": "Normal",
|
||||
"profile_text_size_lg": "Grand",
|
||||
"profile_text_size_xl": "Très grand"
|
||||
}
|
||||
412
ui/messages/id.json
Normal file
412
ui/messages/id.json
Normal file
@@ -0,0 +1,412 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"nav_library": "Perpustakaan",
|
||||
"nav_catalogue": "Katalog",
|
||||
"nav_feedback": "Masukan",
|
||||
"nav_admin": "Admin",
|
||||
"nav_profile": "Profil",
|
||||
"nav_sign_in": "Masuk",
|
||||
"nav_sign_out": "Keluar",
|
||||
"nav_toggle_menu": "Menu",
|
||||
"nav_admin_panel": "Panel admin",
|
||||
|
||||
"footer_library": "Perpustakaan",
|
||||
"footer_catalogue": "Katalog",
|
||||
"footer_feedback": "Masukan",
|
||||
"footer_disclaimer": "Penyangkalan",
|
||||
"footer_privacy": "Privasi",
|
||||
"footer_dmca": "DMCA",
|
||||
"footer_copyright": "© {year} libnovel",
|
||||
"footer_dev": "dev",
|
||||
|
||||
"home_title": "libnovel",
|
||||
"home_stat_books": "Buku",
|
||||
"home_stat_chapters": "Bab",
|
||||
"home_stat_in_progress": "Sedang dibaca",
|
||||
"home_continue_reading": "Lanjutkan Membaca",
|
||||
"home_view_all": "Lihat semua",
|
||||
"home_recently_updated": "Baru Diperbarui",
|
||||
"home_from_following": "Dari Orang yang Kamu Ikuti",
|
||||
"home_empty_title": "Perpustakaanmu kosong",
|
||||
"home_empty_body": "Temukan novel dan tambahkan ke perpustakaanmu.",
|
||||
"home_discover_novels": "Temukan Novel",
|
||||
"home_via_reader": "via {username}",
|
||||
"home_chapter_badge": "bab.{n}",
|
||||
|
||||
"player_generating": "Membuat… {percent}%",
|
||||
"player_loading": "Memuat…",
|
||||
"player_chapters": "Bab",
|
||||
"player_chapter_n": "Bab {n}",
|
||||
"player_toggle_chapter_list": "Daftar bab",
|
||||
"player_chapter_list_label": "Daftar bab",
|
||||
"player_close_chapter_list": "Tutup daftar bab",
|
||||
"player_rewind_15": "Mundur 15 detik",
|
||||
"player_skip_30": "Maju 30 detik",
|
||||
"player_back_15": "−15 dtk",
|
||||
"player_forward_30": "+30 dtk",
|
||||
"player_play": "Putar",
|
||||
"player_pause": "Jeda",
|
||||
"player_speed_label": "Kecepatan {speed}x",
|
||||
"player_seek_label": "Kemajuan bab",
|
||||
"player_change_speed": "Ubah kecepatan",
|
||||
"player_auto_next_on": "Auto-lanjut aktif",
|
||||
"player_auto_next_off": "Auto-lanjut nonaktif",
|
||||
"player_auto_next_ready": "Auto-lanjut — Bab.{n} siap",
|
||||
"player_auto_next_preparing": "Auto-lanjut — menyiapkan Bab.{n}…",
|
||||
"player_auto_next_aria": "Auto-lanjut {state}",
|
||||
"player_go_to_chapter": "Pergi ke bab",
|
||||
"player_close": "Tutup pemutar",
|
||||
|
||||
"login_page_title": "Masuk — libnovel",
|
||||
"login_heading": "Masuk ke libnovel",
|
||||
"login_subheading": "Pilih penyedia untuk melanjutkan",
|
||||
"login_continue_google": "Lanjutkan dengan Google",
|
||||
"login_continue_github": "Lanjutkan dengan GitHub",
|
||||
"login_terms_notice": "Dengan masuk, kamu menyetujui syarat layanan kami.",
|
||||
"login_error_oauth_state": "Masuk dibatalkan atau kedaluwarsa. Coba lagi.",
|
||||
"login_error_oauth_failed": "Tidak dapat terhubung ke penyedia. Coba lagi.",
|
||||
"login_error_oauth_no_email": "Akunmu tidak memiliki alamat email terverifikasi. Tambahkan dan coba lagi.",
|
||||
|
||||
"books_page_title": "Perpustakaan — libnovel",
|
||||
"books_heading": "Perpustakaanmu",
|
||||
"books_empty_title": "Belum ada buku",
|
||||
"books_empty_body": "Tambahkan buku ke perpustakaanmu dengan mengunjungi halaman buku.",
|
||||
"books_browse_catalogue": "Jelajahi Katalog",
|
||||
"books_chapter_count": "{n} bab",
|
||||
"books_last_read": "Terakhir: Bab.{n}",
|
||||
"books_reading_progress": "Bab.{current} / {total}",
|
||||
"books_remove": "Hapus",
|
||||
|
||||
"catalogue_page_title": "Katalog — libnovel",
|
||||
"catalogue_heading": "Katalog",
|
||||
"catalogue_search_placeholder": "Cari novel…",
|
||||
"catalogue_filter_genre": "Genre",
|
||||
"catalogue_filter_status": "Status",
|
||||
"catalogue_filter_sort": "Urutkan",
|
||||
"catalogue_sort_popular": "Populer",
|
||||
"catalogue_sort_new": "Terbaru",
|
||||
"catalogue_sort_top_rated": "Nilai Tertinggi",
|
||||
"catalogue_sort_rank": "Peringkat",
|
||||
"catalogue_status_all": "Semua",
|
||||
"catalogue_status_ongoing": "Berlangsung",
|
||||
"catalogue_status_completed": "Selesai",
|
||||
"catalogue_genre_all": "Semua genre",
|
||||
"catalogue_clear_filters": "Hapus",
|
||||
"catalogue_reset": "Atur ulang",
|
||||
"catalogue_no_results": "Novel tidak ditemukan.",
|
||||
"catalogue_loading": "Memuat…",
|
||||
"catalogue_load_more": "Muat lebih banyak",
|
||||
"catalogue_results_count": "{n} hasil",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_signin_to_save": "Masuk untuk menyimpan",
|
||||
"book_detail_add_to_library": "Tambah ke Perpustakaan",
|
||||
"book_detail_remove_from_library": "Hapus dari Perpustakaan",
|
||||
"book_detail_read_now": "Baca Sekarang",
|
||||
"book_detail_continue_reading": "Lanjutkan Membaca",
|
||||
"book_detail_start_reading": "Mulai Membaca",
|
||||
"book_detail_chapters": "{n} Bab",
|
||||
"book_detail_status": "Status",
|
||||
"book_detail_author": "Penulis",
|
||||
"book_detail_genres": "Genre",
|
||||
"book_detail_description": "Deskripsi",
|
||||
"book_detail_source": "Sumber",
|
||||
"book_detail_rescrape": "Perbarui",
|
||||
"book_detail_scraping": "Memperbarui…",
|
||||
"book_detail_in_library": "Ada di Perpustakaan",
|
||||
|
||||
"chapters_page_title": "Bab — {title}",
|
||||
"chapters_heading": "Bab",
|
||||
"chapters_back_to_book": "Kembali ke buku",
|
||||
"chapters_reading_now": "Sedang dibaca",
|
||||
"chapters_empty": "Belum ada bab yang diambil.",
|
||||
|
||||
"reader_page_title": "{title} — Bab.{n} — libnovel",
|
||||
"reader_play_narration": "Putar narasi",
|
||||
"reader_generating_audio": "Membuat audio…",
|
||||
"reader_signin_for_audio": "Narasi audio tersedia",
|
||||
"reader_signin_audio_desc": "Masuk untuk mendengarkan bab ini yang dinarasikan oleh AI.",
|
||||
"reader_audio_error": "Pembuatan audio gagal.",
|
||||
"reader_prev_chapter": "Bab sebelumnya",
|
||||
"reader_next_chapter": "Bab berikutnya",
|
||||
"reader_back_to_chapters": "Kembali ke daftar bab",
|
||||
"reader_chapter_n": "Bab {n}",
|
||||
"reader_change_voice": "Ganti suara",
|
||||
"reader_voice_panel_title": "Pilih suara",
|
||||
"reader_voice_kokoro": "Suara Kokoro",
|
||||
"reader_voice_pocket": "Suara Pocket-TTS",
|
||||
"reader_voice_play_sample": "Putar sampel",
|
||||
"reader_voice_stop_sample": "Hentikan sampel",
|
||||
"reader_voice_selected": "Dipilih",
|
||||
"reader_close_voice_panel": "Tutup panel suara",
|
||||
"reader_auto_next": "Auto-lanjut",
|
||||
"reader_speed": "Kecepatan",
|
||||
"reader_preview_notice": "Pratinjau — bab ini belum sepenuhnya diambil.",
|
||||
|
||||
"profile_page_title": "Profil — libnovel",
|
||||
"profile_heading": "Profil",
|
||||
"profile_avatar_label": "Avatar",
|
||||
"profile_change_avatar": "Ubah avatar",
|
||||
"profile_username": "Nama pengguna",
|
||||
"profile_email": "Email",
|
||||
"profile_change_password": "Ubah kata sandi",
|
||||
"profile_current_password": "Kata sandi saat ini",
|
||||
"profile_new_password": "Kata sandi baru",
|
||||
"profile_confirm_password": "Konfirmasi kata sandi",
|
||||
"profile_save_password": "Simpan kata sandi",
|
||||
"profile_appearance_heading": "Tampilan",
|
||||
"profile_theme_label": "Tema",
|
||||
"profile_theme_amber": "Amber",
|
||||
"profile_theme_slate": "Abu-abu",
|
||||
"profile_theme_rose": "Mawar",
|
||||
"profile_theme_light": "Light",
|
||||
"profile_theme_light_slate": "Light Blue",
|
||||
"profile_theme_light_rose": "Light Rose",
|
||||
"profile_reading_heading": "Pengaturan membaca",
|
||||
"profile_voice_label": "Suara default",
|
||||
"profile_speed_label": "Kecepatan pemutaran",
|
||||
"profile_auto_next_label": "Auto-lanjut bab",
|
||||
"profile_save_settings": "Simpan pengaturan",
|
||||
"profile_settings_saved": "Pengaturan disimpan.",
|
||||
"profile_settings_error": "Gagal menyimpan pengaturan.",
|
||||
"profile_password_saved": "Kata sandi diubah.",
|
||||
"profile_password_error": "Gagal mengubah kata sandi.",
|
||||
"profile_sessions_heading": "Sesi aktif",
|
||||
"profile_sign_out_all": "Keluar dari semua perangkat lain",
|
||||
"profile_joined": "Bergabung {date}",
|
||||
|
||||
"user_page_title": "{username} — libnovel",
|
||||
"user_library_heading": "Perpustakaan {username}",
|
||||
"user_follow": "Ikuti",
|
||||
"user_unfollow": "Berhenti mengikuti",
|
||||
"user_followers": "{n} pengikut",
|
||||
"user_following": "{n} mengikuti",
|
||||
"user_library_empty": "Tidak ada buku di perpustakaan.",
|
||||
|
||||
"error_not_found_title": "Halaman tidak ditemukan",
|
||||
"error_not_found_body": "Halaman yang kamu cari tidak ada.",
|
||||
"error_generic_title": "Terjadi kesalahan",
|
||||
"error_go_home": "Ke beranda",
|
||||
"error_status": "Error {status}",
|
||||
|
||||
"admin_scrape_page_title": "Scrape — Admin",
|
||||
"admin_scrape_heading": "Scrape",
|
||||
"admin_scrape_catalogue": "Scrape Katalog",
|
||||
"admin_scrape_book": "Scrape Buku",
|
||||
"admin_scrape_url_placeholder": "URL buku di novelfire.net",
|
||||
"admin_scrape_range": "Rentang bab",
|
||||
"admin_scrape_from": "Dari",
|
||||
"admin_scrape_to": "Sampai",
|
||||
"admin_scrape_submit": "Scrape",
|
||||
"admin_scrape_cancel": "Batal",
|
||||
"admin_scrape_status_pending": "Menunggu",
|
||||
"admin_scrape_status_running": "Berjalan",
|
||||
"admin_scrape_status_done": "Selesai",
|
||||
"admin_scrape_status_failed": "Gagal",
|
||||
"admin_scrape_status_cancelled": "Dibatalkan",
|
||||
"admin_tasks_heading": "Tugas terbaru",
|
||||
"admin_tasks_empty": "Belum ada tugas.",
|
||||
|
||||
"admin_audio_page_title": "Audio — Admin",
|
||||
"admin_audio_heading": "Tugas Audio",
|
||||
"admin_audio_empty": "Tidak ada tugas audio.",
|
||||
|
||||
"admin_changelog_page_title": "Changelog — Admin",
|
||||
"admin_changelog_heading": "Changelog",
|
||||
|
||||
"comments_heading": "Komentar",
|
||||
"comments_empty": "Belum ada komentar. Jadilah yang pertama!",
|
||||
"comments_placeholder": "Tulis komentar…",
|
||||
"comments_submit": "Kirim",
|
||||
"comments_login_prompt": "Masuk untuk berkomentar.",
|
||||
"comments_vote_up": "Suka",
|
||||
"comments_vote_down": "Tidak suka",
|
||||
"comments_delete": "Hapus",
|
||||
"comments_reply": "Balas",
|
||||
"comments_show_replies": "Tampilkan {n} balasan",
|
||||
"comments_hide_replies": "Sembunyikan balasan",
|
||||
"comments_edited": "diedit",
|
||||
"comments_deleted": "[dihapus]",
|
||||
|
||||
"disclaimer_page_title": "Penyangkalan — libnovel",
|
||||
"privacy_page_title": "Kebijakan Privasi — libnovel",
|
||||
"dmca_page_title": "DMCA — libnovel",
|
||||
"terms_page_title": "Syarat Layanan — libnovel",
|
||||
|
||||
"common_loading": "Memuat…",
|
||||
"common_error": "Error",
|
||||
"common_save": "Simpan",
|
||||
"common_cancel": "Batal",
|
||||
"common_close": "Tutup",
|
||||
"common_search": "Cari",
|
||||
"common_back": "Kembali",
|
||||
"common_next": "Berikutnya",
|
||||
"common_previous": "Sebelumnya",
|
||||
"common_yes": "Ya",
|
||||
"common_no": "Tidak",
|
||||
"common_on": "aktif",
|
||||
"common_off": "nonaktif",
|
||||
|
||||
"locale_switcher_label": "Bahasa",
|
||||
|
||||
"books_empty_library": "Perpustakaanmu kosong.",
|
||||
"books_empty_discover": "Buku yang mulai kamu baca atau simpan dari",
|
||||
"books_empty_discover_link": "Temukan",
|
||||
"books_empty_discover_suffix": "akan muncul di sini.",
|
||||
"books_count": "{n} buku",
|
||||
|
||||
"catalogue_sort_updated": "Diperbarui",
|
||||
"catalogue_search_button": "Cari",
|
||||
"catalogue_refresh": "Segarkan",
|
||||
"catalogue_refreshing": "Mengantri…",
|
||||
"catalogue_refresh_mobile": "Segarkan katalog",
|
||||
"catalogue_all_loaded": "Semua novel telah dimuat",
|
||||
"catalogue_scroll_top": "Kembali ke atas",
|
||||
"catalogue_view_grid": "Tampilan kisi",
|
||||
"catalogue_view_list": "Tampilan daftar",
|
||||
"catalogue_browse_source": "Jelajahi novel dari novelfire.net",
|
||||
"catalogue_search_results": "{n} hasil untuk \"{q}\"",
|
||||
"catalogue_search_local_count": "({local} lokal, {remote} dari novelfire)",
|
||||
"catalogue_rank_ranked": "{n} novel diurutkan dari scrape katalog terakhir",
|
||||
"catalogue_rank_no_data": "Tidak ada data peringkat.",
|
||||
"catalogue_rank_no_data_body": "Tidak ada data peringkat — jalankan scrape katalog penuh untuk mengisi",
|
||||
"catalogue_rank_run_scrape_admin": "Klik Segarkan katalog di atas untuk memicu scrape katalog penuh.",
|
||||
"catalogue_rank_run_scrape_user": "Minta admin untuk menjalankan scrape katalog.",
|
||||
"catalogue_scrape_queued_flash": "Scrape katalog penuh diantrekan. Perpustakaan dan peringkat akan diperbarui saat buku diproses.",
|
||||
"catalogue_scrape_busy_flash": "Pekerjaan scrape sedang berjalan. Periksa kembali setelah selesai.",
|
||||
"catalogue_scrape_error_flash": "Gagal mengantrekan scrape. Pastikan layanan scraper dapat dijangkau.",
|
||||
"catalogue_filters_label": "Filter",
|
||||
"catalogue_apply": "Terapkan",
|
||||
"catalogue_filter_rank_note": "Filter genre & status hanya berlaku untuk Jelajahi",
|
||||
"catalogue_no_results_search": "Tidak ada hasil.",
|
||||
"catalogue_no_results_try": "Coba kata kunci lain.",
|
||||
"catalogue_no_results_filters": "Coba filter lain atau periksa kembali nanti.",
|
||||
"catalogue_scrape_queued_badge": "Diantrekan",
|
||||
"catalogue_scrape_busy_badge": "Scraper sibuk",
|
||||
"catalogue_scrape_busy_list": "Sibuk",
|
||||
"catalogue_scrape_forbidden_badge": "Terlarang",
|
||||
"catalogue_scrape_novel_button": "Scrape",
|
||||
"catalogue_scraping_novel": "Scraping…",
|
||||
|
||||
"book_detail_not_in_library": "tidak di perpustakaan",
|
||||
"book_detail_continue_ch": "Lanjutkan bab.{n}",
|
||||
"book_detail_start_ch1": "Mulai dari bab.1",
|
||||
"book_detail_preview_ch1": "Pratinjau bab.1",
|
||||
"book_detail_reading_ch": "Membaca bab.{n} dari {total}",
|
||||
"book_detail_n_chapters": "{n} bab",
|
||||
"book_detail_rescraping": "Mengantri…",
|
||||
"book_detail_from_chapter": "Dari bab",
|
||||
"book_detail_to_chapter": "Sampai bab (opsional)",
|
||||
"book_detail_range_queuing": "Mengantri…",
|
||||
"book_detail_scrape_range": "Rentang scrape",
|
||||
"book_detail_admin": "Admin",
|
||||
"book_detail_scraping_progress": "Mengambil 20 bab pertama. Halaman ini akan dimuat ulang otomatis.",
|
||||
"book_detail_scraping_home": "← Beranda",
|
||||
"book_detail_rescrape_book": "Scrape ulang buku",
|
||||
"book_detail_less": "Lebih sedikit",
|
||||
"book_detail_more": "Selengkapnya",
|
||||
|
||||
"chapters_search_placeholder": "Cari bab…",
|
||||
"chapters_jump_to": "Loncat ke Bab.{n}",
|
||||
"chapters_no_match": "Tidak ada bab yang cocok dengan \"{q}\"",
|
||||
"chapters_none_available": "Belum ada bab tersedia.",
|
||||
"chapters_reading_indicator": "sedang dibaca",
|
||||
"chapters_result_count": "{n} hasil",
|
||||
|
||||
"reader_fetching_chapter": "Mengambil bab…",
|
||||
"reader_words": "{n} kata",
|
||||
"reader_preview_audio_notice": "Pratinjau — audio tidak tersedia untuk buku di luar perpustakaan.",
|
||||
|
||||
"profile_click_to_change": "Klik avatar untuk mengganti foto",
|
||||
"profile_tts_voice": "Suara TTS",
|
||||
"profile_auto_advance": "Otomatis lanjut ke bab berikutnya",
|
||||
"profile_saving": "Menyimpan…",
|
||||
"profile_saved": "Tersimpan!",
|
||||
"profile_session_this": "Sesi ini",
|
||||
"profile_session_signed_in": "Masuk {date}",
|
||||
"profile_session_last_seen": "· Terakhir dilihat {date}",
|
||||
"profile_session_sign_out": "Keluar",
|
||||
"profile_session_end": "Akhiri",
|
||||
"profile_session_unrecognised": "Ini semua perangkat yang masuk ke akunmu. Akhiri sesi yang tidak kamu kenali.",
|
||||
"profile_no_sessions": "Tidak ada catatan sesi. Sesi dilacak mulai login berikutnya.",
|
||||
"profile_change_password_heading": "Ubah kata sandi",
|
||||
"profile_update_password": "Perbarui kata sandi",
|
||||
"profile_updating": "Memperbarui…",
|
||||
"profile_password_changed_ok": "Kata sandi berhasil diubah.",
|
||||
"profile_playback_speed": "Kecepatan pemutaran — {speed}x",
|
||||
|
||||
"profile_subscription_heading": "Langganan",
|
||||
"profile_plan_pro": "Pro",
|
||||
"profile_plan_free": "Gratis",
|
||||
"profile_pro_active": "Langganan Pro kamu aktif.",
|
||||
"profile_pro_perks": "Audio tanpa batas, semua bahasa terjemahan, dan pilihan suara tersedia.",
|
||||
"profile_manage_subscription": "Kelola langganan",
|
||||
"profile_upgrade_heading": "Tingkatkan ke Pro",
|
||||
"profile_upgrade_desc": "Buka audio tanpa batas, terjemahan dalam 4 bahasa, dan pilihan suara.",
|
||||
"profile_upgrade_monthly": "Bulanan — $6 / bln",
|
||||
"profile_upgrade_annual": "Tahunan — $48 / thn",
|
||||
"profile_free_limits": "Paket gratis: 3 bab audio per hari, hanya bahasa Inggris.",
|
||||
|
||||
"user_currently_reading": "Sedang Dibaca",
|
||||
"user_library_count": "Perpustakaan ({n})",
|
||||
"user_joined": "Bergabung {date}",
|
||||
"user_followers_label": "pengikut",
|
||||
"user_following_label": "mengikuti",
|
||||
"user_no_books": "Belum ada buku di perpustakaan.",
|
||||
|
||||
"admin_pages_label": "Halaman",
|
||||
"admin_tools_label": "Alat",
|
||||
|
||||
"admin_scrape_status_idle": "Menunggu",
|
||||
"admin_scrape_full_catalogue": "Katalog penuh",
|
||||
"admin_scrape_single_book": "Satu buku",
|
||||
"admin_scrape_quick_genres": "Genre cepat",
|
||||
"admin_scrape_task_history": "Riwayat tugas",
|
||||
"admin_scrape_filter_placeholder": "Filter berdasarkan jenis, status, atau URL…",
|
||||
"admin_scrape_no_matching": "Tidak ada tugas yang cocok.",
|
||||
"admin_scrape_start": "Mulai scrape",
|
||||
"admin_scrape_queuing": "Mengantri…",
|
||||
"admin_scrape_running": "Berjalan…",
|
||||
|
||||
"admin_audio_filter_jobs": "Filter berdasarkan slug, suara, atau status…",
|
||||
"admin_audio_filter_cache": "Filter berdasarkan slug, bab, atau suara…",
|
||||
"admin_audio_no_matching_jobs": "Tidak ada pekerjaan yang cocok.",
|
||||
"admin_audio_no_jobs": "Belum ada pekerjaan audio.",
|
||||
"admin_audio_cache_empty": "Cache audio kosong.",
|
||||
"admin_audio_no_cache_results": "Tidak ada hasil.",
|
||||
|
||||
"admin_changelog_gitea": "Rilis Gitea",
|
||||
"admin_changelog_no_releases": "Tidak ada rilis.",
|
||||
"admin_changelog_load_error": "Gagal memuat rilis: {error}",
|
||||
|
||||
"comments_top": "Teratas",
|
||||
"comments_new": "Terbaru",
|
||||
"comments_posting": "Mengirim…",
|
||||
"comments_login_link": "Masuk",
|
||||
"comments_login_suffix": "untuk meninggalkan komentar.",
|
||||
"comments_anonymous": "Anonim",
|
||||
|
||||
"reader_audio_narration": "Narasi Audio",
|
||||
"reader_playing": "Memutar — kontrol di bawah",
|
||||
"reader_paused": "Dijeda — kontrol di bawah",
|
||||
"reader_ch_ready": "Bab.{n} siap",
|
||||
"reader_ch_preparing": "Menyiapkan Bab.{n}… {percent}%",
|
||||
"reader_ch_generate_on_nav": "Bab.{n} akan dihasilkan saat navigasi",
|
||||
"reader_now_playing": "Sedang diputar: {title}",
|
||||
"reader_load_this_chapter": "Muat bab ini",
|
||||
"reader_generate_samples": "Hasilkan sampel yang hilang",
|
||||
"reader_voice_applies_next": "Suara baru berlaku pada \"Putar narasi\" berikutnya.",
|
||||
"reader_choose_voice": "Pilih Suara",
|
||||
"reader_generating_narration": "Membuat narasi…",
|
||||
|
||||
"profile_font_family": "Jenis Font",
|
||||
"profile_font_system": "Sistem",
|
||||
"profile_font_serif": "Serif",
|
||||
"profile_font_mono": "Mono",
|
||||
"profile_text_size": "Ukuran Teks",
|
||||
"profile_text_size_sm": "Kecil",
|
||||
"profile_text_size_md": "Normal",
|
||||
"profile_text_size_lg": "Besar",
|
||||
"profile_text_size_xl": "Sangat Besar"
|
||||
}
|
||||
412
ui/messages/pt.json
Normal file
412
ui/messages/pt.json
Normal file
@@ -0,0 +1,412 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"nav_library": "Biblioteca",
|
||||
"nav_catalogue": "Catálogo",
|
||||
"nav_feedback": "Feedback",
|
||||
"nav_admin": "Admin",
|
||||
"nav_profile": "Perfil",
|
||||
"nav_sign_in": "Entrar",
|
||||
"nav_sign_out": "Sair",
|
||||
"nav_toggle_menu": "Menu",
|
||||
"nav_admin_panel": "Painel admin",
|
||||
|
||||
"footer_library": "Biblioteca",
|
||||
"footer_catalogue": "Catálogo",
|
||||
"footer_feedback": "Feedback",
|
||||
"footer_disclaimer": "Aviso legal",
|
||||
"footer_privacy": "Privacidade",
|
||||
"footer_dmca": "DMCA",
|
||||
"footer_copyright": "© {year} libnovel",
|
||||
"footer_dev": "dev",
|
||||
|
||||
"home_title": "libnovel",
|
||||
"home_stat_books": "Livros",
|
||||
"home_stat_chapters": "Capítulos",
|
||||
"home_stat_in_progress": "Em andamento",
|
||||
"home_continue_reading": "Continuar Lendo",
|
||||
"home_view_all": "Ver tudo",
|
||||
"home_recently_updated": "Atualizados Recentemente",
|
||||
"home_from_following": "De Quem Você Segue",
|
||||
"home_empty_title": "Sua biblioteca está vazia",
|
||||
"home_empty_body": "Descubra romances e adicione à sua biblioteca.",
|
||||
"home_discover_novels": "Descobrir Romances",
|
||||
"home_via_reader": "via {username}",
|
||||
"home_chapter_badge": "cap.{n}",
|
||||
|
||||
"player_generating": "Gerando… {percent}%",
|
||||
"player_loading": "Carregando…",
|
||||
"player_chapters": "Capítulos",
|
||||
"player_chapter_n": "Capítulo {n}",
|
||||
"player_toggle_chapter_list": "Lista de capítulos",
|
||||
"player_chapter_list_label": "Lista de capítulos",
|
||||
"player_close_chapter_list": "Fechar lista de capítulos",
|
||||
"player_rewind_15": "Voltar 15 segundos",
|
||||
"player_skip_30": "Avançar 30 segundos",
|
||||
"player_back_15": "−15 s",
|
||||
"player_forward_30": "+30 s",
|
||||
"player_play": "Reproduzir",
|
||||
"player_pause": "Pausar",
|
||||
"player_speed_label": "Velocidade {speed}x",
|
||||
"player_seek_label": "Progresso do capítulo",
|
||||
"player_change_speed": "Mudar velocidade",
|
||||
"player_auto_next_on": "Próximo automático ativado",
|
||||
"player_auto_next_off": "Próximo automático desativado",
|
||||
"player_auto_next_ready": "Próximo automático — Cap.{n} pronto",
|
||||
"player_auto_next_preparing": "Próximo automático — preparando Cap.{n}…",
|
||||
"player_auto_next_aria": "Próximo automático {state}",
|
||||
"player_go_to_chapter": "Ir para capítulo",
|
||||
"player_close": "Fechar player",
|
||||
|
||||
"login_page_title": "Entrar — libnovel",
|
||||
"login_heading": "Entrar no libnovel",
|
||||
"login_subheading": "Escolha um provedor para continuar",
|
||||
"login_continue_google": "Continuar com Google",
|
||||
"login_continue_github": "Continuar com GitHub",
|
||||
"login_terms_notice": "Ao entrar, você concorda com nossos termos de serviço.",
|
||||
"login_error_oauth_state": "Login cancelado ou expirado. Tente novamente.",
|
||||
"login_error_oauth_failed": "Não foi possível conectar ao provedor. Tente novamente.",
|
||||
"login_error_oauth_no_email": "Sua conta não tem endereço de email verificado. Adicione um e tente novamente.",
|
||||
|
||||
"books_page_title": "Biblioteca — libnovel",
|
||||
"books_heading": "Sua Biblioteca",
|
||||
"books_empty_title": "Nenhum livro ainda",
|
||||
"books_empty_body": "Adicione livros à sua biblioteca visitando a página de um livro.",
|
||||
"books_browse_catalogue": "Explorar Catálogo",
|
||||
"books_chapter_count": "{n} capítulos",
|
||||
"books_last_read": "Último: Cap.{n}",
|
||||
"books_reading_progress": "Cap.{current} / {total}",
|
||||
"books_remove": "Remover",
|
||||
|
||||
"catalogue_page_title": "Catálogo — libnovel",
|
||||
"catalogue_heading": "Catálogo",
|
||||
"catalogue_search_placeholder": "Pesquisar romances…",
|
||||
"catalogue_filter_genre": "Gênero",
|
||||
"catalogue_filter_status": "Status",
|
||||
"catalogue_filter_sort": "Ordenar",
|
||||
"catalogue_sort_popular": "Popular",
|
||||
"catalogue_sort_new": "Novo",
|
||||
"catalogue_sort_top_rated": "Mais Bem Avaliados",
|
||||
"catalogue_sort_rank": "Ranking",
|
||||
"catalogue_status_all": "Todos",
|
||||
"catalogue_status_ongoing": "Em andamento",
|
||||
"catalogue_status_completed": "Concluído",
|
||||
"catalogue_genre_all": "Todos os gêneros",
|
||||
"catalogue_clear_filters": "Limpar",
|
||||
"catalogue_reset": "Redefinir",
|
||||
"catalogue_no_results": "Nenhum romance encontrado.",
|
||||
"catalogue_loading": "Carregando…",
|
||||
"catalogue_load_more": "Carregar mais",
|
||||
"catalogue_results_count": "{n} resultados",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_signin_to_save": "Entre para salvar",
|
||||
"book_detail_add_to_library": "Adicionar à Biblioteca",
|
||||
"book_detail_remove_from_library": "Remover da Biblioteca",
|
||||
"book_detail_read_now": "Ler Agora",
|
||||
"book_detail_continue_reading": "Continuar Lendo",
|
||||
"book_detail_start_reading": "Começar a Ler",
|
||||
"book_detail_chapters": "{n} Capítulos",
|
||||
"book_detail_status": "Status",
|
||||
"book_detail_author": "Autor",
|
||||
"book_detail_genres": "Gêneros",
|
||||
"book_detail_description": "Descrição",
|
||||
"book_detail_source": "Fonte",
|
||||
"book_detail_rescrape": "Atualizar",
|
||||
"book_detail_scraping": "Atualizando…",
|
||||
"book_detail_in_library": "Na Biblioteca",
|
||||
|
||||
"chapters_page_title": "Capítulos — {title}",
|
||||
"chapters_heading": "Capítulos",
|
||||
"chapters_back_to_book": "Voltar ao livro",
|
||||
"chapters_reading_now": "Lendo",
|
||||
"chapters_empty": "Nenhum capítulo extraído ainda.",
|
||||
|
||||
"reader_page_title": "{title} — Cap.{n} — libnovel",
|
||||
"reader_play_narration": "Reproduzir narração",
|
||||
"reader_generating_audio": "Gerando áudio…",
|
||||
"reader_signin_for_audio": "Narração de áudio disponível",
|
||||
"reader_signin_audio_desc": "Entre para ouvir este capítulo narrado por IA.",
|
||||
"reader_audio_error": "Falha na geração de áudio.",
|
||||
"reader_prev_chapter": "Capítulo anterior",
|
||||
"reader_next_chapter": "Próximo capítulo",
|
||||
"reader_back_to_chapters": "Voltar aos capítulos",
|
||||
"reader_chapter_n": "Capítulo {n}",
|
||||
"reader_change_voice": "Mudar voz",
|
||||
"reader_voice_panel_title": "Selecionar voz",
|
||||
"reader_voice_kokoro": "Vozes Kokoro",
|
||||
"reader_voice_pocket": "Vozes Pocket-TTS",
|
||||
"reader_voice_play_sample": "Reproduzir amostra",
|
||||
"reader_voice_stop_sample": "Parar amostra",
|
||||
"reader_voice_selected": "Selecionado",
|
||||
"reader_close_voice_panel": "Fechar painel de voz",
|
||||
"reader_auto_next": "Próximo automático",
|
||||
"reader_speed": "Velocidade",
|
||||
"reader_preview_notice": "Prévia — este capítulo não foi totalmente extraído.",
|
||||
|
||||
"profile_page_title": "Perfil — libnovel",
|
||||
"profile_heading": "Perfil",
|
||||
"profile_avatar_label": "Avatar",
|
||||
"profile_change_avatar": "Mudar avatar",
|
||||
"profile_username": "Nome de usuário",
|
||||
"profile_email": "Email",
|
||||
"profile_change_password": "Mudar senha",
|
||||
"profile_current_password": "Senha atual",
|
||||
"profile_new_password": "Nova senha",
|
||||
"profile_confirm_password": "Confirmar senha",
|
||||
"profile_save_password": "Salvar senha",
|
||||
"profile_appearance_heading": "Aparência",
|
||||
"profile_theme_label": "Tema",
|
||||
"profile_theme_amber": "Âmbar",
|
||||
"profile_theme_slate": "Ardósia",
|
||||
"profile_theme_rose": "Rosa",
|
||||
"profile_theme_light": "Light",
|
||||
"profile_theme_light_slate": "Light Blue",
|
||||
"profile_theme_light_rose": "Light Rose",
|
||||
"profile_reading_heading": "Configurações de leitura",
|
||||
"profile_voice_label": "Voz padrão",
|
||||
"profile_speed_label": "Velocidade de reprodução",
|
||||
"profile_auto_next_label": "Próximo capítulo automático",
|
||||
"profile_save_settings": "Salvar configurações",
|
||||
"profile_settings_saved": "Configurações salvas.",
|
||||
"profile_settings_error": "Falha ao salvar configurações.",
|
||||
"profile_password_saved": "Senha alterada.",
|
||||
"profile_password_error": "Falha ao alterar a senha.",
|
||||
"profile_sessions_heading": "Sessões ativas",
|
||||
"profile_sign_out_all": "Sair de todos os outros dispositivos",
|
||||
"profile_joined": "Entrou em {date}",
|
||||
|
||||
"user_page_title": "{username} — libnovel",
|
||||
"user_library_heading": "Biblioteca de {username}",
|
||||
"user_follow": "Seguir",
|
||||
"user_unfollow": "Deixar de seguir",
|
||||
"user_followers": "{n} seguidores",
|
||||
"user_following": "{n} seguindo",
|
||||
"user_library_empty": "Nenhum livro na biblioteca.",
|
||||
|
||||
"error_not_found_title": "Página não encontrada",
|
||||
"error_not_found_body": "A página que você procura não existe.",
|
||||
"error_generic_title": "Algo deu errado",
|
||||
"error_go_home": "Ir para início",
|
||||
"error_status": "Erro {status}",
|
||||
|
||||
"admin_scrape_page_title": "Extração — Admin",
|
||||
"admin_scrape_heading": "Extração",
|
||||
"admin_scrape_catalogue": "Extrair Catálogo",
|
||||
"admin_scrape_book": "Extrair Livro",
|
||||
"admin_scrape_url_placeholder": "URL do livro em novelfire.net",
|
||||
"admin_scrape_range": "Intervalo de capítulos",
|
||||
"admin_scrape_from": "De",
|
||||
"admin_scrape_to": "Até",
|
||||
"admin_scrape_submit": "Extrair",
|
||||
"admin_scrape_cancel": "Cancelar",
|
||||
"admin_scrape_status_pending": "Pendente",
|
||||
"admin_scrape_status_running": "Em execução",
|
||||
"admin_scrape_status_done": "Concluído",
|
||||
"admin_scrape_status_failed": "Falhou",
|
||||
"admin_scrape_status_cancelled": "Cancelado",
|
||||
"admin_tasks_heading": "Tarefas recentes",
|
||||
"admin_tasks_empty": "Nenhuma tarefa ainda.",
|
||||
|
||||
"admin_audio_page_title": "Áudio — Admin",
|
||||
"admin_audio_heading": "Tarefas de Áudio",
|
||||
"admin_audio_empty": "Nenhuma tarefa de áudio.",
|
||||
|
||||
"admin_changelog_page_title": "Changelog — Admin",
|
||||
"admin_changelog_heading": "Changelog",
|
||||
|
||||
"comments_heading": "Comentários",
|
||||
"comments_empty": "Nenhum comentário ainda. Seja o primeiro!",
|
||||
"comments_placeholder": "Escreva um comentário…",
|
||||
"comments_submit": "Publicar",
|
||||
"comments_login_prompt": "Entre para comentar.",
|
||||
"comments_vote_up": "Votar positivo",
|
||||
"comments_vote_down": "Votar negativo",
|
||||
"comments_delete": "Excluir",
|
||||
"comments_reply": "Responder",
|
||||
"comments_show_replies": "Mostrar {n} respostas",
|
||||
"comments_hide_replies": "Ocultar respostas",
|
||||
"comments_edited": "editado",
|
||||
"comments_deleted": "[excluído]",
|
||||
|
||||
"disclaimer_page_title": "Aviso Legal — libnovel",
|
||||
"privacy_page_title": "Política de Privacidade — libnovel",
|
||||
"dmca_page_title": "DMCA — libnovel",
|
||||
"terms_page_title": "Termos de Serviço — libnovel",
|
||||
|
||||
"common_loading": "Carregando…",
|
||||
"common_error": "Erro",
|
||||
"common_save": "Salvar",
|
||||
"common_cancel": "Cancelar",
|
||||
"common_close": "Fechar",
|
||||
"common_search": "Pesquisar",
|
||||
"common_back": "Voltar",
|
||||
"common_next": "Próximo",
|
||||
"common_previous": "Anterior",
|
||||
"common_yes": "Sim",
|
||||
"common_no": "Não",
|
||||
"common_on": "ativado",
|
||||
"common_off": "desativado",
|
||||
|
||||
"locale_switcher_label": "Idioma",
|
||||
|
||||
"books_empty_library": "Sua biblioteca está vazia.",
|
||||
"books_empty_discover": "Livros que você começar a ler ou salvar de",
|
||||
"books_empty_discover_link": "Descobrir",
|
||||
"books_empty_discover_suffix": "aparecerão aqui.",
|
||||
"books_count": "{n} livro{s}",
|
||||
|
||||
"catalogue_sort_updated": "Atualizado",
|
||||
"catalogue_search_button": "Pesquisar",
|
||||
"catalogue_refresh": "Atualizar",
|
||||
"catalogue_refreshing": "Na fila…",
|
||||
"catalogue_refresh_mobile": "Atualizar catálogo",
|
||||
"catalogue_all_loaded": "Todos os romances carregados",
|
||||
"catalogue_scroll_top": "Voltar ao topo",
|
||||
"catalogue_view_grid": "Visualização em grade",
|
||||
"catalogue_view_list": "Visualização em lista",
|
||||
"catalogue_browse_source": "Explorar romances do novelfire.net",
|
||||
"catalogue_search_results": "{n} resultado{s} para \"{q}\"",
|
||||
"catalogue_search_local_count": "({local} local, {remote} do novelfire)",
|
||||
"catalogue_rank_ranked": "{n} romances classificados do último scrape do catálogo",
|
||||
"catalogue_rank_no_data": "Sem dados de classificação.",
|
||||
"catalogue_rank_no_data_body": "Sem dados de classificação — execute um scrape completo do catálogo para preencher",
|
||||
"catalogue_rank_run_scrape_admin": "Clique em Atualizar catálogo acima para acionar um scrape completo.",
|
||||
"catalogue_rank_run_scrape_user": "Peça a um administrador para executar um scrape do catálogo.",
|
||||
"catalogue_scrape_queued_flash": "Scrape completo do catálogo na fila. A biblioteca e a classificação serão atualizadas conforme os livros forem processados.",
|
||||
"catalogue_scrape_busy_flash": "Um job de scrape já está em execução. Volte quando terminar.",
|
||||
"catalogue_scrape_error_flash": "Falha ao enfileirar o scrape. Verifique se o serviço de scraper está acessível.",
|
||||
"catalogue_filters_label": "Filtros",
|
||||
"catalogue_apply": "Aplicar",
|
||||
"catalogue_filter_rank_note": "Filtros de gênero e status se aplicam apenas a Explorar",
|
||||
"catalogue_no_results_search": "Nenhum resultado encontrado.",
|
||||
"catalogue_no_results_try": "Tente um termo de pesquisa diferente.",
|
||||
"catalogue_no_results_filters": "Tente filtros diferentes ou volte mais tarde.",
|
||||
"catalogue_scrape_queued_badge": "Na fila",
|
||||
"catalogue_scrape_busy_badge": "Scraper ocupado",
|
||||
"catalogue_scrape_busy_list": "Ocupado",
|
||||
"catalogue_scrape_forbidden_badge": "Proibido",
|
||||
"catalogue_scrape_novel_button": "Extrair",
|
||||
"catalogue_scraping_novel": "Extraindo…",
|
||||
|
||||
"book_detail_not_in_library": "não está na biblioteca",
|
||||
"book_detail_continue_ch": "Continuar cap.{n}",
|
||||
"book_detail_start_ch1": "Começar pelo cap.1",
|
||||
"book_detail_preview_ch1": "Prévia do cap.1",
|
||||
"book_detail_reading_ch": "Lendo cap.{n} de {total}",
|
||||
"book_detail_n_chapters": "{n} capítulos",
|
||||
"book_detail_rescraping": "Na fila…",
|
||||
"book_detail_from_chapter": "A partir do capítulo",
|
||||
"book_detail_to_chapter": "Até o capítulo (opcional)",
|
||||
"book_detail_range_queuing": "Na fila…",
|
||||
"book_detail_scrape_range": "Intervalo de extração",
|
||||
"book_detail_admin": "Admin",
|
||||
"book_detail_scraping_progress": "Buscando os primeiros 20 capítulos. Esta página será atualizada automaticamente.",
|
||||
"book_detail_scraping_home": "← Início",
|
||||
"book_detail_rescrape_book": "Reextrair livro",
|
||||
"book_detail_less": "Menos",
|
||||
"book_detail_more": "Mais",
|
||||
|
||||
"chapters_search_placeholder": "Pesquisar capítulos…",
|
||||
"chapters_jump_to": "Ir para Cap.{n}",
|
||||
"chapters_no_match": "Nenhum capítulo encontrado para \"{q}\"",
|
||||
"chapters_none_available": "Nenhum capítulo disponível ainda.",
|
||||
"chapters_reading_indicator": "lendo",
|
||||
"chapters_result_count": "{n} resultados",
|
||||
|
||||
"reader_fetching_chapter": "Buscando capítulo…",
|
||||
"reader_words": "{n} palavras",
|
||||
"reader_preview_audio_notice": "Prévia — áudio não disponível para livros fora da biblioteca.",
|
||||
|
||||
"profile_click_to_change": "Clique no avatar para mudar a foto",
|
||||
"profile_tts_voice": "Voz TTS",
|
||||
"profile_auto_advance": "Avançar automaticamente para o próximo capítulo",
|
||||
"profile_saving": "Salvando…",
|
||||
"profile_saved": "Salvo!",
|
||||
"profile_session_this": "Esta sessão",
|
||||
"profile_session_signed_in": "Entrou em {date}",
|
||||
"profile_session_last_seen": "· Visto por último em {date}",
|
||||
"profile_session_sign_out": "Sair",
|
||||
"profile_session_end": "Encerrar",
|
||||
"profile_session_unrecognised": "Estes são todos os dispositivos conectados à sua conta. Encerre qualquer sessão que não reconhecer.",
|
||||
"profile_no_sessions": "Nenhum registro de sessão encontrado. As sessões são rastreadas a partir do próximo login.",
|
||||
"profile_change_password_heading": "Mudar senha",
|
||||
"profile_update_password": "Atualizar senha",
|
||||
"profile_updating": "Atualizando…",
|
||||
"profile_password_changed_ok": "Senha alterada com sucesso.",
|
||||
"profile_playback_speed": "Velocidade de reprodução — {speed}x",
|
||||
|
||||
"profile_subscription_heading": "Assinatura",
|
||||
"profile_plan_pro": "Pro",
|
||||
"profile_plan_free": "Gratuito",
|
||||
"profile_pro_active": "Sua assinatura Pro está ativa.",
|
||||
"profile_pro_perks": "Áudio ilimitado, todos os idiomas de tradução e seleção de voz estão habilitados.",
|
||||
"profile_manage_subscription": "Gerenciar assinatura",
|
||||
"profile_upgrade_heading": "Assinar o Pro",
|
||||
"profile_upgrade_desc": "Desbloqueie áudio ilimitado, traduções em 4 idiomas e seleção de voz.",
|
||||
"profile_upgrade_monthly": "Mensal — $6 / mês",
|
||||
"profile_upgrade_annual": "Anual — $48 / ano",
|
||||
"profile_free_limits": "Plano gratuito: 3 capítulos de áudio por dia, somente inglês.",
|
||||
|
||||
"user_currently_reading": "Lendo Agora",
|
||||
"user_library_count": "Biblioteca ({n})",
|
||||
"user_joined": "Entrou em {date}",
|
||||
"user_followers_label": "seguidores",
|
||||
"user_following_label": "seguindo",
|
||||
"user_no_books": "Nenhum livro na biblioteca ainda.",
|
||||
|
||||
"admin_pages_label": "Páginas",
|
||||
"admin_tools_label": "Ferramentas",
|
||||
|
||||
"admin_scrape_status_idle": "Ocioso",
|
||||
"admin_scrape_full_catalogue": "Catálogo completo",
|
||||
"admin_scrape_single_book": "Livro único",
|
||||
"admin_scrape_quick_genres": "Gêneros rápidos",
|
||||
"admin_scrape_task_history": "Histórico de tarefas",
|
||||
"admin_scrape_filter_placeholder": "Filtrar por tipo, status ou URL…",
|
||||
"admin_scrape_no_matching": "Nenhuma tarefa correspondente.",
|
||||
"admin_scrape_start": "Iniciar extração",
|
||||
"admin_scrape_queuing": "Na fila…",
|
||||
"admin_scrape_running": "Executando…",
|
||||
|
||||
"admin_audio_filter_jobs": "Filtrar por slug, voz ou status…",
|
||||
"admin_audio_filter_cache": "Filtrar por slug, capítulo ou voz…",
|
||||
"admin_audio_no_matching_jobs": "Nenhum job correspondente.",
|
||||
"admin_audio_no_jobs": "Nenhum job de áudio ainda.",
|
||||
"admin_audio_cache_empty": "Cache de áudio vazio.",
|
||||
"admin_audio_no_cache_results": "Sem resultados.",
|
||||
|
||||
"admin_changelog_gitea": "Releases do Gitea",
|
||||
"admin_changelog_no_releases": "Nenhum release encontrado.",
|
||||
"admin_changelog_load_error": "Não foi possível carregar os releases: {error}",
|
||||
|
||||
"comments_top": "Mais votados",
|
||||
"comments_new": "Novos",
|
||||
"comments_posting": "Publicando…",
|
||||
"comments_login_link": "Entre",
|
||||
"comments_login_suffix": "para deixar um comentário.",
|
||||
"comments_anonymous": "Anônimo",
|
||||
|
||||
"reader_audio_narration": "Narração em Áudio",
|
||||
"reader_playing": "Reproduzindo — controles abaixo",
|
||||
"reader_paused": "Pausado — controles abaixo",
|
||||
"reader_ch_ready": "Cap.{n} pronto",
|
||||
"reader_ch_preparing": "Preparando Cap.{n}… {percent}%",
|
||||
"reader_ch_generate_on_nav": "Cap.{n} será gerado ao navegar",
|
||||
"reader_now_playing": "Reproduzindo: {title}",
|
||||
"reader_load_this_chapter": "Carregar este capítulo",
|
||||
"reader_generate_samples": "Gerar amostras ausentes",
|
||||
"reader_voice_applies_next": "A nova voz será aplicada no próximo \"Reproduzir narração\".",
|
||||
"reader_choose_voice": "Escolher Voz",
|
||||
"reader_generating_narration": "Gerando narração…",
|
||||
|
||||
"profile_font_family": "Fonte",
|
||||
"profile_font_system": "Sistema",
|
||||
"profile_font_serif": "Serif",
|
||||
"profile_font_mono": "Mono",
|
||||
"profile_text_size": "Tamanho do texto",
|
||||
"profile_text_size_sm": "Pequeno",
|
||||
"profile_text_size_md": "Normal",
|
||||
"profile_text_size_lg": "Grande",
|
||||
"profile_text_size_xl": "Muito grande"
|
||||
}
|
||||
412
ui/messages/ru.json
Normal file
412
ui/messages/ru.json
Normal file
@@ -0,0 +1,412 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"nav_library": "Библиотека",
|
||||
"nav_catalogue": "Каталог",
|
||||
"nav_feedback": "Обратная связь",
|
||||
"nav_admin": "Админ",
|
||||
"nav_profile": "Профиль",
|
||||
"nav_sign_in": "Войти",
|
||||
"nav_sign_out": "Выйти",
|
||||
"nav_toggle_menu": "Меню",
|
||||
"nav_admin_panel": "Панель администратора",
|
||||
|
||||
"footer_library": "Библиотека",
|
||||
"footer_catalogue": "Каталог",
|
||||
"footer_feedback": "Обратная связь",
|
||||
"footer_disclaimer": "Отказ от ответственности",
|
||||
"footer_privacy": "Конфиденциальность",
|
||||
"footer_dmca": "DMCA",
|
||||
"footer_copyright": "© {year} libnovel",
|
||||
"footer_dev": "dev",
|
||||
|
||||
"home_title": "libnovel",
|
||||
"home_stat_books": "Книги",
|
||||
"home_stat_chapters": "Главы",
|
||||
"home_stat_in_progress": "В процессе",
|
||||
"home_continue_reading": "Продолжить чтение",
|
||||
"home_view_all": "Смотреть все",
|
||||
"home_recently_updated": "Недавно обновлённые",
|
||||
"home_from_following": "От авторов, на которых вы подписаны",
|
||||
"home_empty_title": "Ваша библиотека пуста",
|
||||
"home_empty_body": "Откройте для себя новеллы и добавьте их в библиотеку.",
|
||||
"home_discover_novels": "Открыть новеллы",
|
||||
"home_via_reader": "от {username}",
|
||||
"home_chapter_badge": "гл.{n}",
|
||||
|
||||
"player_generating": "Генерация… {percent}%",
|
||||
"player_loading": "Загрузка…",
|
||||
"player_chapters": "Главы",
|
||||
"player_chapter_n": "Глава {n}",
|
||||
"player_toggle_chapter_list": "Список глав",
|
||||
"player_chapter_list_label": "Список глав",
|
||||
"player_close_chapter_list": "Закрыть список глав",
|
||||
"player_rewind_15": "Назад 15 секунд",
|
||||
"player_skip_30": "Вперёд 30 секунд",
|
||||
"player_back_15": "−15 сек",
|
||||
"player_forward_30": "+30 сек",
|
||||
"player_play": "Воспроизвести",
|
||||
"player_pause": "Пауза",
|
||||
"player_speed_label": "Скорость {speed}x",
|
||||
"player_seek_label": "Прогресс главы",
|
||||
"player_change_speed": "Изменить скорость",
|
||||
"player_auto_next_on": "Автопереход вкл.",
|
||||
"player_auto_next_off": "Автопереход выкл.",
|
||||
"player_auto_next_ready": "Автопереход — гл.{n} готова",
|
||||
"player_auto_next_preparing": "Автопереход — подготовка гл.{n}…",
|
||||
"player_auto_next_aria": "Автопереход {state}",
|
||||
"player_go_to_chapter": "Перейти к главе",
|
||||
"player_close": "Закрыть плеер",
|
||||
|
||||
"login_page_title": "Вход — libnovel",
|
||||
"login_heading": "Войти в libnovel",
|
||||
"login_subheading": "Выберите провайдера для входа",
|
||||
"login_continue_google": "Продолжить с Google",
|
||||
"login_continue_github": "Продолжить с GitHub",
|
||||
"login_terms_notice": "Входя, вы принимаете наши условия использования.",
|
||||
"login_error_oauth_state": "Вход отменён или истёк срок действия. Попробуйте снова.",
|
||||
"login_error_oauth_failed": "Не удалось подключиться к провайдеру. Попробуйте снова.",
|
||||
"login_error_oauth_no_email": "У вашего аккаунта нет подтверждённого email. Добавьте его и повторите попытку.",
|
||||
|
||||
"books_page_title": "Библиотека — libnovel",
|
||||
"books_heading": "Ваша библиотека",
|
||||
"books_empty_title": "Книг пока нет",
|
||||
"books_empty_body": "Добавляйте книги в библиотеку, посещая страницы книг.",
|
||||
"books_browse_catalogue": "Обзор каталога",
|
||||
"books_chapter_count": "{n} глав",
|
||||
"books_last_read": "Последнее: гл.{n}",
|
||||
"books_reading_progress": "Гл.{current} / {total}",
|
||||
"books_remove": "Удалить",
|
||||
|
||||
"catalogue_page_title": "Каталог — libnovel",
|
||||
"catalogue_heading": "Каталог",
|
||||
"catalogue_search_placeholder": "Поиск новелл…",
|
||||
"catalogue_filter_genre": "Жанр",
|
||||
"catalogue_filter_status": "Статус",
|
||||
"catalogue_filter_sort": "Сортировка",
|
||||
"catalogue_sort_popular": "Популярные",
|
||||
"catalogue_sort_new": "Новые",
|
||||
"catalogue_sort_top_rated": "Топ по рейтингу",
|
||||
"catalogue_sort_rank": "По рангу",
|
||||
"catalogue_status_all": "Все",
|
||||
"catalogue_status_ongoing": "Продолжаются",
|
||||
"catalogue_status_completed": "Завершены",
|
||||
"catalogue_genre_all": "Все жанры",
|
||||
"catalogue_clear_filters": "Сбросить",
|
||||
"catalogue_reset": "Сброс",
|
||||
"catalogue_no_results": "Новеллы не найдены.",
|
||||
"catalogue_loading": "Загрузка…",
|
||||
"catalogue_load_more": "Загрузить ещё",
|
||||
"catalogue_results_count": "{n} результатов",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_signin_to_save": "Войдите, чтобы сохранить",
|
||||
"book_detail_add_to_library": "В библиотеку",
|
||||
"book_detail_remove_from_library": "Удалить из библиотеки",
|
||||
"book_detail_read_now": "Читать",
|
||||
"book_detail_continue_reading": "Продолжить чтение",
|
||||
"book_detail_start_reading": "Начать чтение",
|
||||
"book_detail_chapters": "{n} глав",
|
||||
"book_detail_status": "Статус",
|
||||
"book_detail_author": "Автор",
|
||||
"book_detail_genres": "Жанры",
|
||||
"book_detail_description": "Описание",
|
||||
"book_detail_source": "Источник",
|
||||
"book_detail_rescrape": "Обновить",
|
||||
"book_detail_scraping": "Обновление…",
|
||||
"book_detail_in_library": "В библиотеке",
|
||||
|
||||
"chapters_page_title": "Главы — {title}",
|
||||
"chapters_heading": "Главы",
|
||||
"chapters_back_to_book": "К книге",
|
||||
"chapters_reading_now": "Читается",
|
||||
"chapters_empty": "Главы ещё не загружены.",
|
||||
|
||||
"reader_page_title": "{title} — Гл.{n} — libnovel",
|
||||
"reader_play_narration": "Воспроизвести озвучку",
|
||||
"reader_generating_audio": "Генерация аудио…",
|
||||
"reader_signin_for_audio": "Доступна аудионарративация",
|
||||
"reader_signin_audio_desc": "Войдите, чтобы слушать эту главу в озвучке ИИ.",
|
||||
"reader_audio_error": "Ошибка генерации аудио.",
|
||||
"reader_prev_chapter": "Предыдущая глава",
|
||||
"reader_next_chapter": "Следующая глава",
|
||||
"reader_back_to_chapters": "К главам",
|
||||
"reader_chapter_n": "Глава {n}",
|
||||
"reader_change_voice": "Сменить голос",
|
||||
"reader_voice_panel_title": "Выбрать голос",
|
||||
"reader_voice_kokoro": "Голоса Kokoro",
|
||||
"reader_voice_pocket": "Голоса Pocket-TTS",
|
||||
"reader_voice_play_sample": "Прослушать образец",
|
||||
"reader_voice_stop_sample": "Остановить образец",
|
||||
"reader_voice_selected": "Выбран",
|
||||
"reader_close_voice_panel": "Закрыть панель голоса",
|
||||
"reader_auto_next": "Автопереход",
|
||||
"reader_speed": "Скорость",
|
||||
"reader_preview_notice": "Предпросмотр — эта глава не полностью загружена.",
|
||||
|
||||
"profile_page_title": "Профиль — libnovel",
|
||||
"profile_heading": "Профиль",
|
||||
"profile_avatar_label": "Аватар",
|
||||
"profile_change_avatar": "Изменить аватар",
|
||||
"profile_username": "Имя пользователя",
|
||||
"profile_email": "Email",
|
||||
"profile_change_password": "Изменить пароль",
|
||||
"profile_current_password": "Текущий пароль",
|
||||
"profile_new_password": "Новый пароль",
|
||||
"profile_confirm_password": "Подтвердить пароль",
|
||||
"profile_save_password": "Сохранить пароль",
|
||||
"profile_appearance_heading": "Внешний вид",
|
||||
"profile_theme_label": "Тема",
|
||||
"profile_theme_amber": "Янтарь",
|
||||
"profile_theme_slate": "Сланец",
|
||||
"profile_theme_rose": "Роза",
|
||||
"profile_theme_light": "Light",
|
||||
"profile_theme_light_slate": "Light Blue",
|
||||
"profile_theme_light_rose": "Light Rose",
|
||||
"profile_reading_heading": "Настройки чтения",
|
||||
"profile_voice_label": "Голос по умолчанию",
|
||||
"profile_speed_label": "Скорость воспроизведения",
|
||||
"profile_auto_next_label": "Автопереход к следующей главе",
|
||||
"profile_save_settings": "Сохранить настройки",
|
||||
"profile_settings_saved": "Настройки сохранены.",
|
||||
"profile_settings_error": "Не удалось сохранить настройки.",
|
||||
"profile_password_saved": "Пароль изменён.",
|
||||
"profile_password_error": "Не удалось изменить пароль.",
|
||||
"profile_sessions_heading": "Активные сессии",
|
||||
"profile_sign_out_all": "Выйти на всех других устройствах",
|
||||
"profile_joined": "Зарегистрирован {date}",
|
||||
|
||||
"user_page_title": "{username} — libnovel",
|
||||
"user_library_heading": "Библиотека {username}",
|
||||
"user_follow": "Подписаться",
|
||||
"user_unfollow": "Отписаться",
|
||||
"user_followers": "{n} подписчиков",
|
||||
"user_following": "{n} подписок",
|
||||
"user_library_empty": "В библиотеке нет книг.",
|
||||
|
||||
"error_not_found_title": "Страница не найдена",
|
||||
"error_not_found_body": "Запрошенная страница не существует.",
|
||||
"error_generic_title": "Что-то пошло не так",
|
||||
"error_go_home": "На главную",
|
||||
"error_status": "Ошибка {status}",
|
||||
|
||||
"admin_scrape_page_title": "Парсинг — Админ",
|
||||
"admin_scrape_heading": "Парсинг",
|
||||
"admin_scrape_catalogue": "Парсинг каталога",
|
||||
"admin_scrape_book": "Парсинг книги",
|
||||
"admin_scrape_url_placeholder": "URL книги на novelfire.net",
|
||||
"admin_scrape_range": "Диапазон глав",
|
||||
"admin_scrape_from": "От",
|
||||
"admin_scrape_to": "До",
|
||||
"admin_scrape_submit": "Парсить",
|
||||
"admin_scrape_cancel": "Отмена",
|
||||
"admin_scrape_status_pending": "Ожидание",
|
||||
"admin_scrape_status_running": "Выполняется",
|
||||
"admin_scrape_status_done": "Готово",
|
||||
"admin_scrape_status_failed": "Ошибка",
|
||||
"admin_scrape_status_cancelled": "Отменено",
|
||||
"admin_tasks_heading": "Последние задачи",
|
||||
"admin_tasks_empty": "Задач пока нет.",
|
||||
|
||||
"admin_audio_page_title": "Аудио — Админ",
|
||||
"admin_audio_heading": "Аудио задачи",
|
||||
"admin_audio_empty": "Аудио задач нет.",
|
||||
|
||||
"admin_changelog_page_title": "Changelog — Админ",
|
||||
"admin_changelog_heading": "Changelog",
|
||||
|
||||
"comments_heading": "Комментарии",
|
||||
"comments_empty": "Комментариев пока нет. Будьте первым!",
|
||||
"comments_placeholder": "Написать комментарий…",
|
||||
"comments_submit": "Отправить",
|
||||
"comments_login_prompt": "Войдите, чтобы комментировать.",
|
||||
"comments_vote_up": "Плюс",
|
||||
"comments_vote_down": "Минус",
|
||||
"comments_delete": "Удалить",
|
||||
"comments_reply": "Ответить",
|
||||
"comments_show_replies": "Показать {n} ответов",
|
||||
"comments_hide_replies": "Скрыть ответы",
|
||||
"comments_edited": "изменено",
|
||||
"comments_deleted": "[удалено]",
|
||||
|
||||
"disclaimer_page_title": "Отказ от ответственности — libnovel",
|
||||
"privacy_page_title": "Политика конфиденциальности — libnovel",
|
||||
"dmca_page_title": "DMCA — libnovel",
|
||||
"terms_page_title": "Условия использования — libnovel",
|
||||
|
||||
"common_loading": "Загрузка…",
|
||||
"common_error": "Ошибка",
|
||||
"common_save": "Сохранить",
|
||||
"common_cancel": "Отмена",
|
||||
"common_close": "Закрыть",
|
||||
"common_search": "Поиск",
|
||||
"common_back": "Назад",
|
||||
"common_next": "Далее",
|
||||
"common_previous": "Назад",
|
||||
"common_yes": "Да",
|
||||
"common_no": "Нет",
|
||||
"common_on": "вкл.",
|
||||
"common_off": "выкл.",
|
||||
|
||||
"locale_switcher_label": "Язык",
|
||||
|
||||
"books_empty_library": "Ваша библиотека пуста.",
|
||||
"books_empty_discover": "Книги, которые вы начнёте читать или сохраните из",
|
||||
"books_empty_discover_link": "Каталога",
|
||||
"books_empty_discover_suffix": "появятся здесь.",
|
||||
"books_count": "{n} книг{s}",
|
||||
|
||||
"catalogue_sort_updated": "По дате обновления",
|
||||
"catalogue_search_button": "Поиск",
|
||||
"catalogue_refresh": "Обновить",
|
||||
"catalogue_refreshing": "В очереди…",
|
||||
"catalogue_refresh_mobile": "Обновить каталог",
|
||||
"catalogue_all_loaded": "Все новеллы загружены",
|
||||
"catalogue_scroll_top": "Вверх",
|
||||
"catalogue_view_grid": "Сетка",
|
||||
"catalogue_view_list": "Список",
|
||||
"catalogue_browse_source": "Смотреть новеллы с novelfire.net",
|
||||
"catalogue_search_results": "{n} результат{s} по запросу «{q}»",
|
||||
"catalogue_search_local_count": "({local} локальных, {remote} с novelfire)",
|
||||
"catalogue_rank_ranked": "{n} новелл отсортированы по последнему парсингу каталога",
|
||||
"catalogue_rank_no_data": "Нет данных рейтинга.",
|
||||
"catalogue_rank_no_data_body": "Нет данных рейтинга — запустите полный парсинг каталога для заполнения",
|
||||
"catalogue_rank_run_scrape_admin": "Нажмите «Обновить каталог» выше, чтобы запустить полный парсинг.",
|
||||
"catalogue_rank_run_scrape_user": "Попросите администратора запустить парсинг каталога.",
|
||||
"catalogue_scrape_queued_flash": "Полный парсинг каталога поставлен в очередь. Библиотека и рейтинг обновятся по мере обработки.",
|
||||
"catalogue_scrape_busy_flash": "Парсинг уже запущен. Проверьте позже.",
|
||||
"catalogue_scrape_error_flash": "Не удалось поставить парсинг в очередь. Проверьте доступность сервиса.",
|
||||
"catalogue_filters_label": "Фильтры",
|
||||
"catalogue_apply": "Применить",
|
||||
"catalogue_filter_rank_note": "Фильтры по жанру и статусу применяются только к разделу «Обзор»",
|
||||
"catalogue_no_results_search": "Ничего не найдено.",
|
||||
"catalogue_no_results_try": "Попробуйте другой запрос.",
|
||||
"catalogue_no_results_filters": "Попробуйте другие фильтры или проверьте позже.",
|
||||
"catalogue_scrape_queued_badge": "В очереди",
|
||||
"catalogue_scrape_busy_badge": "Парсер занят",
|
||||
"catalogue_scrape_busy_list": "Занят",
|
||||
"catalogue_scrape_forbidden_badge": "Запрещено",
|
||||
"catalogue_scrape_novel_button": "Парсить",
|
||||
"catalogue_scraping_novel": "Парсинг…",
|
||||
|
||||
"book_detail_not_in_library": "не в библиотеке",
|
||||
"book_detail_continue_ch": "Продолжить гл.{n}",
|
||||
"book_detail_start_ch1": "Начать с гл.1",
|
||||
"book_detail_preview_ch1": "Предпросмотр гл.1",
|
||||
"book_detail_reading_ch": "Читается гл.{n} из {total}",
|
||||
"book_detail_n_chapters": "{n} глав",
|
||||
"book_detail_rescraping": "В очереди…",
|
||||
"book_detail_from_chapter": "С главы",
|
||||
"book_detail_to_chapter": "До главы (необязательно)",
|
||||
"book_detail_range_queuing": "В очереди…",
|
||||
"book_detail_scrape_range": "Диапазон глав",
|
||||
"book_detail_admin": "Администрирование",
|
||||
"book_detail_scraping_progress": "Загружаются первые 20 глав. Страница обновится автоматически.",
|
||||
"book_detail_scraping_home": "← На главную",
|
||||
"book_detail_rescrape_book": "Перепарсить книгу",
|
||||
"book_detail_less": "Скрыть",
|
||||
"book_detail_more": "Ещё",
|
||||
|
||||
"chapters_search_placeholder": "Поиск глав…",
|
||||
"chapters_jump_to": "Перейти к гл.{n}",
|
||||
"chapters_no_match": "Главы по запросу «{q}» не найдены",
|
||||
"chapters_none_available": "Глав пока нет.",
|
||||
"chapters_reading_indicator": "читается",
|
||||
"chapters_result_count": "{n} результатов",
|
||||
|
||||
"reader_fetching_chapter": "Загрузка главы…",
|
||||
"reader_words": "{n} слов",
|
||||
"reader_preview_audio_notice": "Предпросмотр — аудио недоступно для книг вне библиотеки.",
|
||||
|
||||
"profile_click_to_change": "Нажмите на аватар для смены фото",
|
||||
"profile_tts_voice": "Голос TTS",
|
||||
"profile_auto_advance": "Автопереход к следующей главе",
|
||||
"profile_saving": "Сохранение…",
|
||||
"profile_saved": "Сохранено!",
|
||||
"profile_session_this": "Текущая сессия",
|
||||
"profile_session_signed_in": "Вход {date}",
|
||||
"profile_session_last_seen": "· Последний визит {date}",
|
||||
"profile_session_sign_out": "Выйти",
|
||||
"profile_session_end": "Завершить",
|
||||
"profile_session_unrecognised": "Это все устройства, авторизованные в вашем аккаунте. Завершите любую сессию, которую не узнаёте.",
|
||||
"profile_no_sessions": "Записей сессий нет. Отслеживание начнётся со следующего входа.",
|
||||
"profile_change_password_heading": "Изменить пароль",
|
||||
"profile_update_password": "Обновить пароль",
|
||||
"profile_updating": "Обновление…",
|
||||
"profile_password_changed_ok": "Пароль успешно изменён.",
|
||||
"profile_playback_speed": "Скорость воспроизведения — {speed}x",
|
||||
|
||||
"profile_subscription_heading": "Подписка",
|
||||
"profile_plan_pro": "Pro",
|
||||
"profile_plan_free": "Бесплатно",
|
||||
"profile_pro_active": "Ваша подписка Pro активна.",
|
||||
"profile_pro_perks": "Безлимитное аудио, все языки перевода и выбор голоса доступны.",
|
||||
"profile_manage_subscription": "Управление подпиской",
|
||||
"profile_upgrade_heading": "Перейти на Pro",
|
||||
"profile_upgrade_desc": "Разблокируйте безлимитное аудио, переводы на 4 языка и выбор голоса.",
|
||||
"profile_upgrade_monthly": "Ежемесячно — $6 / мес",
|
||||
"profile_upgrade_annual": "Ежегодно — $48 / год",
|
||||
"profile_free_limits": "Бесплатный план: 3 аудиоглавы в день, только английский.",
|
||||
|
||||
"user_currently_reading": "Сейчас читает",
|
||||
"user_library_count": "Библиотека ({n})",
|
||||
"user_joined": "Зарегистрирован {date}",
|
||||
"user_followers_label": "подписчиков",
|
||||
"user_following_label": "подписок",
|
||||
"user_no_books": "Книг в библиотеке пока нет.",
|
||||
|
||||
"admin_pages_label": "Страницы",
|
||||
"admin_tools_label": "Инструменты",
|
||||
|
||||
"admin_scrape_status_idle": "Ожидание",
|
||||
"admin_scrape_full_catalogue": "Полный каталог",
|
||||
"admin_scrape_single_book": "Одна книга",
|
||||
"admin_scrape_quick_genres": "Быстрые жанры",
|
||||
"admin_scrape_task_history": "История задач",
|
||||
"admin_scrape_filter_placeholder": "Фильтр по типу, статусу или URL…",
|
||||
"admin_scrape_no_matching": "Задач не найдено.",
|
||||
"admin_scrape_start": "Начать парсинг",
|
||||
"admin_scrape_queuing": "В очереди…",
|
||||
"admin_scrape_running": "Выполняется…",
|
||||
|
||||
"admin_audio_filter_jobs": "Фильтр по slug, голосу или статусу…",
|
||||
"admin_audio_filter_cache": "Фильтр по slug, главе или голосу…",
|
||||
"admin_audio_no_matching_jobs": "Заданий не найдено.",
|
||||
"admin_audio_no_jobs": "Аудиозаданий пока нет.",
|
||||
"admin_audio_cache_empty": "Аудиокэш пуст.",
|
||||
"admin_audio_no_cache_results": "Результатов нет.",
|
||||
|
||||
"admin_changelog_gitea": "Релизы Gitea",
|
||||
"admin_changelog_no_releases": "Релизов не найдено.",
|
||||
"admin_changelog_load_error": "Не удалось загрузить релизы: {error}",
|
||||
|
||||
"comments_top": "Лучшие",
|
||||
"comments_new": "Новые",
|
||||
"comments_posting": "Отправка…",
|
||||
"comments_login_link": "Войдите",
|
||||
"comments_login_suffix": "чтобы оставить комментарий.",
|
||||
"comments_anonymous": "Аноним",
|
||||
|
||||
"reader_audio_narration": "Аудионарратив",
|
||||
"reader_playing": "Воспроизводится — управление ниже",
|
||||
"reader_paused": "Пауза — управление ниже",
|
||||
"reader_ch_ready": "Гл.{n} готова",
|
||||
"reader_ch_preparing": "Подготовка гл.{n}… {percent}%",
|
||||
"reader_ch_generate_on_nav": "Гл.{n} сгенерируется при переходе",
|
||||
"reader_now_playing": "Сейчас играет: {title}",
|
||||
"reader_load_this_chapter": "Загрузить эту главу",
|
||||
"reader_generate_samples": "Сгенерировать недостающие образцы",
|
||||
"reader_voice_applies_next": "Новый голос применится при следующем нажатии «Воспроизвести».",
|
||||
"reader_choose_voice": "Выбрать голос",
|
||||
"reader_generating_narration": "Генерация озвучки…",
|
||||
|
||||
"profile_font_family": "Шрифт",
|
||||
"profile_font_system": "Системный",
|
||||
"profile_font_serif": "Serif",
|
||||
"profile_font_mono": "Моноширинный",
|
||||
"profile_text_size": "Размер текста",
|
||||
"profile_text_size_sm": "Маленький",
|
||||
"profile_text_size_md": "Нормальный",
|
||||
"profile_text_size_lg": "Большой",
|
||||
"profile_text_size_xl": "Очень большой"
|
||||
}
|
||||
222
ui/package-lock.json
generated
222
ui/package-lock.json
generated
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1005.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||
"@inlang/paraglide-js": "^2.15.1",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/resources": "^2.6.1",
|
||||
@@ -1719,6 +1720,49 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@inlang/paraglide-js": {
|
||||
"version": "2.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.15.1.tgz",
|
||||
"integrity": "sha512-7wWKbLWwLx1dkkYz55TnVp+39atKXf7rnlHnL8adSmM73UaAdB9fXDzo24GHSY/6FPGFKSkgHdT2qyJv2whWsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inlang/recommend-sherlock": "^0.2.1",
|
||||
"@inlang/sdk": "^2.9.1",
|
||||
"commander": "11.1.0",
|
||||
"consola": "3.4.0",
|
||||
"json5": "2.2.3",
|
||||
"unplugin": "^2.1.2",
|
||||
"urlpattern-polyfill": "^10.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"paraglide-js": "bin/run.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@inlang/recommend-sherlock": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz",
|
||||
"integrity": "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"comment-json": "^4.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@inlang/sdk": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.9.1.tgz",
|
||||
"integrity": "sha512-y0C3xaKo6pSGDr3p5OdreRVT3THJpgKVe1lLvG3BE4v9lskp3UfI9cPCbN8X2dpfLt/4ljtehMb5SykpMfJrMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lix-js/sdk": "0.4.9",
|
||||
"@sinclair/typebox": "^0.31.17",
|
||||
"kysely": "^0.28.12",
|
||||
"sqlite-wasm-kysely": "0.3.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
|
||||
@@ -1780,6 +1824,43 @@
|
||||
"url": "https://opencollective.com/js-sdsl"
|
||||
}
|
||||
},
|
||||
"node_modules/@lix-js/sdk": {
|
||||
"version": "0.4.9",
|
||||
"resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.9.tgz",
|
||||
"integrity": "sha512-30mDkXpx704359oRrJI42bjfCspCiaMItngVBbPkiTGypS7xX4jYbHWQkXI8XuJ7VDB69D0MsVU6xfrBAIrM4A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@lix-js/server-protocol-schema": "0.1.1",
|
||||
"dedent": "1.5.1",
|
||||
"human-id": "^4.1.1",
|
||||
"js-sha256": "^0.11.0",
|
||||
"kysely": "^0.28.12",
|
||||
"sqlite-wasm-kysely": "0.3.0",
|
||||
"uuid": "^10.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@lix-js/sdk/node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@lix-js/server-protocol-schema": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz",
|
||||
"integrity": "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
@@ -4135,6 +4216,12 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.31.28",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.28.tgz",
|
||||
"integrity": "sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@smithy/abort-controller": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.11.tgz",
|
||||
@@ -4867,6 +4954,15 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sqlite.org/sqlite-wasm": {
|
||||
"version": "3.48.0-build4",
|
||||
"resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.48.0-build4.tgz",
|
||||
"integrity": "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"sqlite-wasm": "bin/index.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
@@ -5405,6 +5501,12 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/array-timsort": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz",
|
||||
"integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ast-types": {
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
|
||||
@@ -5590,6 +5692,28 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
|
||||
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/comment-json": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz",
|
||||
"integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"array-timsort": "^1.0.3",
|
||||
"esprima": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/commondir": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||
@@ -5597,6 +5721,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz",
|
||||
"integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -5635,6 +5768,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/dedent": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
|
||||
"integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"babel-plugin-macros": "^3.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"babel-plugin-macros": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
@@ -5966,6 +6113,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/human-id": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.3.tgz",
|
||||
"integrity": "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"human-id": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/import-in-the-middle": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.0.tgz",
|
||||
@@ -6062,6 +6218,12 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-sha256": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz",
|
||||
"integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -6101,6 +6263,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/kysely": {
|
||||
"version": "0.28.14",
|
||||
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.14.tgz",
|
||||
"integrity": "sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.31.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||
@@ -6988,6 +7159,17 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sqlite-wasm-kysely": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/sqlite-wasm-kysely/-/sqlite-wasm-kysely-0.3.0.tgz",
|
||||
"integrity": "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==",
|
||||
"dependencies": {
|
||||
"@sqlite.org/sqlite-wasm": "^3.48.0-build2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"kysely": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/standard-as-callback": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||
@@ -7201,6 +7383,21 @@
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "2.3.11",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
|
||||
"integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"acorn": "^8.15.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -7231,6 +7428,25 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/urlpattern-polyfill": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz",
|
||||
"integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
@@ -7330,6 +7546,12 @@
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/webpack-virtual-modules": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"prepare": "paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide && svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
@@ -30,6 +30,7 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1005.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||
"@inlang/paraglide-js": "^2.15.1",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
||||
"@opentelemetry/resources": "^2.6.1",
|
||||
|
||||
11
ui/project.inlang/settings.json
Normal file
11
ui/project.inlang/settings.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/project-settings",
|
||||
"baseLocale": "en",
|
||||
"locales": ["en", "ru", "id", "pt", "fr"],
|
||||
"modules": [
|
||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format/dist/index.js"
|
||||
],
|
||||
"plugin.inlang.messageFormat": {
|
||||
"pathPattern": "./messages/{locale}.json"
|
||||
}
|
||||
}
|
||||
107
ui/src/app.css
107
ui/src/app.css
@@ -8,6 +8,93 @@
|
||||
--color-surface-3: #3f3f46; /* zinc-700 */
|
||||
--color-muted: #a1a1aa; /* zinc-400 */
|
||||
--color-text: #f4f4f5; /* zinc-100 */
|
||||
--color-border: #3f3f46; /* zinc-700 */
|
||||
--color-danger: #f87171; /* red-400 */
|
||||
--color-success: #4ade80; /* green-400 */
|
||||
}
|
||||
|
||||
/* ── Amber theme (default) — same as @theme above, explicit for clarity ── */
|
||||
[data-theme="amber"] {
|
||||
--color-brand: #f59e0b;
|
||||
--color-brand-dim: #d97706;
|
||||
--color-surface: #18181b;
|
||||
--color-surface-2: #27272a;
|
||||
--color-surface-3: #3f3f46;
|
||||
--color-muted: #a1a1aa;
|
||||
--color-text: #f4f4f5;
|
||||
--color-border: #3f3f46;
|
||||
--color-danger: #f87171;
|
||||
--color-success: #4ade80;
|
||||
}
|
||||
|
||||
/* ── Slate theme — indigo/slate dark ─────────────────────────────────── */
|
||||
[data-theme="slate"] {
|
||||
--color-brand: #818cf8; /* indigo-400 */
|
||||
--color-brand-dim: #4f46e5; /* indigo-600 */
|
||||
--color-surface: #0f172a; /* slate-900 */
|
||||
--color-surface-2: #1e293b; /* slate-800 */
|
||||
--color-surface-3: #334155; /* slate-700 */
|
||||
--color-muted: #94a3b8; /* slate-400 */
|
||||
--color-text: #f1f5f9; /* slate-100 */
|
||||
--color-border: #334155; /* slate-700 */
|
||||
--color-danger: #f87171; /* red-400 */
|
||||
--color-success: #4ade80; /* green-400 */
|
||||
}
|
||||
|
||||
/* ── Rose theme — dark pink ───────────────────────────────────────────── */
|
||||
[data-theme="rose"] {
|
||||
--color-brand: #fb7185; /* rose-400 */
|
||||
--color-brand-dim: #e11d48; /* rose-600 */
|
||||
--color-surface: #18181b; /* zinc-900 */
|
||||
--color-surface-2: #1c1318; /* custom dark rose */
|
||||
--color-surface-3: #2d1f26; /* custom dark rose-2 */
|
||||
--color-muted: #a1a1aa; /* zinc-400 */
|
||||
--color-text: #f4f4f5; /* zinc-100 */
|
||||
--color-border: #3f2d36; /* custom rose border */
|
||||
--color-danger: #f87171; /* red-400 */
|
||||
--color-success: #4ade80; /* green-400 */
|
||||
}
|
||||
|
||||
/* ── Light amber theme ────────────────────────────────────────────────── */
|
||||
[data-theme="light"] {
|
||||
--color-brand: #d97706; /* amber-600 */
|
||||
--color-brand-dim: #b45309; /* amber-700 */
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-2: #f4f4f5; /* zinc-100 */
|
||||
--color-surface-3: #e4e4e7; /* zinc-200 */
|
||||
--color-muted: #71717a; /* zinc-500 */
|
||||
--color-text: #18181b; /* zinc-900 */
|
||||
--color-border: #d4d4d8; /* zinc-300 */
|
||||
--color-danger: #dc2626; /* red-600 */
|
||||
--color-success: #16a34a; /* green-600 */
|
||||
}
|
||||
|
||||
/* ── Light slate theme ────────────────────────────────────────────────── */
|
||||
[data-theme="light-slate"] {
|
||||
--color-brand: #4f46e5; /* indigo-600 */
|
||||
--color-brand-dim: #4338ca; /* indigo-700 */
|
||||
--color-surface: #f8fafc; /* slate-50 */
|
||||
--color-surface-2: #f1f5f9; /* slate-100 */
|
||||
--color-surface-3: #e2e8f0; /* slate-200 */
|
||||
--color-muted: #64748b; /* slate-500 */
|
||||
--color-text: #0f172a; /* slate-900 */
|
||||
--color-border: #cbd5e1; /* slate-300 */
|
||||
--color-danger: #dc2626; /* red-600 */
|
||||
--color-success: #16a34a; /* green-600 */
|
||||
}
|
||||
|
||||
/* ── Light rose theme ─────────────────────────────────────────────────── */
|
||||
[data-theme="light-rose"] {
|
||||
--color-brand: #e11d48; /* rose-600 */
|
||||
--color-brand-dim: #be123c; /* rose-700 */
|
||||
--color-surface: #fff1f2; /* rose-50 */
|
||||
--color-surface-2: #ffe4e6; /* rose-100 */
|
||||
--color-surface-3: #fecdd3; /* rose-200 */
|
||||
--color-muted: #9f1239; /* rose-800 at 60% */
|
||||
--color-text: #0f0a0b; /* near black */
|
||||
--color-border: #fda4af; /* rose-300 */
|
||||
--color-danger: #dc2626; /* red-600 */
|
||||
--color-success: #16a34a; /* green-600 */
|
||||
}
|
||||
|
||||
html {
|
||||
@@ -15,18 +102,25 @@ html {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ── Reading typography custom properties ──────────────────────────── */
|
||||
:root {
|
||||
--reading-font: system-ui, -apple-system, sans-serif;
|
||||
--reading-size: 1.05rem;
|
||||
}
|
||||
|
||||
/* ── Chapter prose ─────────────────────────────────────────────────── */
|
||||
.prose-chapter {
|
||||
max-width: 72ch;
|
||||
line-height: 1.85;
|
||||
font-size: 1.05rem;
|
||||
color: #d4d4d8; /* zinc-300 */
|
||||
font-family: var(--reading-font);
|
||||
font-size: var(--reading-size);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.prose-chapter h1,
|
||||
.prose-chapter h2,
|
||||
.prose-chapter h3 {
|
||||
color: #f4f4f5;
|
||||
color: var(--color-text);
|
||||
font-weight: 700;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
@@ -41,15 +135,15 @@ html {
|
||||
}
|
||||
|
||||
.prose-chapter em {
|
||||
color: #a1a1aa;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.prose-chapter strong {
|
||||
color: #f4f4f5;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.prose-chapter hr {
|
||||
border-color: #3f3f46;
|
||||
border-color: var(--color-border);
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
@@ -62,4 +156,3 @@ html {
|
||||
.animate-progress-bar {
|
||||
animation: progress-bar 8s cubic-bezier(0.1, 0.05, 0.1, 1) forwards;
|
||||
}
|
||||
|
||||
|
||||
2
ui/src/app.d.ts
vendored
2
ui/src/app.d.ts
vendored
@@ -6,9 +6,11 @@ declare global {
|
||||
interface Locals {
|
||||
sessionId: string;
|
||||
user: { id: string; username: string; role: string; authSessionId: string } | null;
|
||||
isPro: boolean;
|
||||
}
|
||||
interface PageData {
|
||||
user?: { id: string; username: string; role: string; authSessionId: string } | null;
|
||||
isPro?: boolean;
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="%lang%" dir="%dir%">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
import { handleErrorWithSentry } from '@sentry/sveltekit';
|
||||
import * as Sentry from '@sentry/sveltekit';
|
||||
import { randomBytes, createHmac } from 'node:crypto';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { env as pubEnv } from '$env/dynamic/public';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { createUserSession, touchUserSession, isSessionRevoked } from '$lib/server/pocketbase';
|
||||
import { createUserSession, touchUserSession, isSessionRevoked, getUserById } from '$lib/server/pocketbase';
|
||||
import { drain as drainPresignCache } from '$lib/server/presignCache';
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
||||
@@ -13,6 +14,7 @@ import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http';
|
||||
import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs';
|
||||
import { resourceFromAttributes } from '@opentelemetry/resources';
|
||||
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
|
||||
import { paraglideMiddleware } from '$lib/paraglide/server';
|
||||
|
||||
// ─── OpenTelemetry server-side tracing + logs ─────────────────────────────────
|
||||
// No-op when OTEL_EXPORTER_OTLP_ENDPOINT is unset (e.g. local dev).
|
||||
@@ -138,7 +140,21 @@ export function parseAuthToken(token: string): { id: string; username: string; r
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
function getTextDirection(locale: string): string {
|
||||
// All supported locales (en, ru, id, pt, fr) are LTR
|
||||
return 'ltr';
|
||||
}
|
||||
|
||||
const paraglideHandle: Handle = ({ event, resolve }) =>
|
||||
paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => {
|
||||
event.request = localizedRequest;
|
||||
return resolve(event, {
|
||||
transformPageChunk: ({ html }) =>
|
||||
html.replace('%lang%', locale).replace('%dir%', getTextDirection(locale))
|
||||
});
|
||||
});
|
||||
|
||||
const appHandle: Handle = async ({ event, resolve }) => {
|
||||
// During graceful shutdown, reject new requests immediately so the load
|
||||
// balancer / Docker health-check can drain existing connections.
|
||||
if (shuttingDown) {
|
||||
@@ -194,6 +210,20 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.user = null;
|
||||
}
|
||||
|
||||
// ── isPro: read fresh from DB so role changes take effect without re-login ──
|
||||
if (event.locals.user) {
|
||||
try {
|
||||
const dbUser = await getUserById(event.locals.user.id);
|
||||
event.locals.isPro = dbUser?.role === 'pro' || dbUser?.role === 'admin';
|
||||
} catch {
|
||||
event.locals.isPro = false;
|
||||
}
|
||||
} else {
|
||||
event.locals.isPro = false;
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
export const handle = sequence(paraglideHandle, appHandle);
|
||||
|
||||
|
||||
4
ui/src/hooks.ts
Normal file
4
ui/src/hooks.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { Reroute } from '@sveltejs/kit';
|
||||
import { deLocalizeUrl } from '$lib/paraglide/runtime';
|
||||
|
||||
export const reroute: Reroute = ({ url }) => deLocalizeUrl(url).pathname;
|
||||
@@ -52,6 +52,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/utils';
|
||||
import type { Voice } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
@@ -66,6 +67,8 @@
|
||||
chapters?: { number: number; title: string }[];
|
||||
/** List of available voices from the backend. */
|
||||
voices?: Voice[];
|
||||
/** Called when the server returns 402 (free daily limit reached). */
|
||||
onProRequired?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -76,7 +79,8 @@
|
||||
cover = '',
|
||||
nextChapter = null,
|
||||
chapters = [],
|
||||
voices = []
|
||||
voices = [],
|
||||
onProRequired = undefined
|
||||
}: Props = $props();
|
||||
|
||||
// ── Derived: voices grouped by engine ──────────────────────────────────
|
||||
@@ -562,6 +566,15 @@
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ voice })
|
||||
});
|
||||
|
||||
if (res.status === 402) {
|
||||
// Free daily limit reached — surface upgrade CTA
|
||||
audioStore.status = 'idle';
|
||||
stopProgress();
|
||||
onProRequired?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
|
||||
|
||||
if (res.status === 200) {
|
||||
@@ -675,7 +688,7 @@
|
||||
<!-- ── Voice row snippet (reused in both engine sections) ──────────────── -->
|
||||
{#snippet voiceRow(v: import('$lib/types').Voice)}
|
||||
<div
|
||||
class={cn('flex items-center gap-2 px-3 py-2 hover:bg-zinc-800 transition-colors cursor-pointer', audioStore.voice === v.id && 'bg-amber-400/10')}
|
||||
class={cn('flex items-center gap-2 px-3 py-2 hover:bg-(--color-surface-2) transition-colors cursor-pointer', audioStore.voice === v.id && 'bg-(--color-brand)/10')}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => selectVoice(v.id)}
|
||||
@@ -684,25 +697,25 @@
|
||||
<!-- Selected indicator -->
|
||||
<div class="w-4 flex-shrink-0">
|
||||
{#if audioStore.voice === v.id}
|
||||
<svg class="w-3.5 h-3.5 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-3.5 h-3.5 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.41L9 16.17z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Voice name -->
|
||||
<span class={cn('flex-1 text-xs', audioStore.voice === v.id ? 'text-amber-400 font-medium' : 'text-zinc-300')}>
|
||||
<span class={cn('flex-1 text-xs', audioStore.voice === v.id ? 'text-(--color-brand) font-medium' : 'text-(--color-text)')}>
|
||||
{voiceLabel(v)}
|
||||
</span>
|
||||
<span class="text-zinc-600 text-xs font-mono">{v.id}</span>
|
||||
<span class="text-(--color-muted) opacity-60 text-xs font-mono">{v.id}</span>
|
||||
|
||||
<!-- Sample play button -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v.id ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : 'text-zinc-500 hover:text-zinc-200')}
|
||||
class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v.id ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
onclick={(e) => { e.stopPropagation(); playSample(v.id); }}
|
||||
title={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
|
||||
title={samplePlayingVoice === v.id ? m.reader_voice_stop_sample() : m.reader_voice_play_sample()}
|
||||
aria-label={samplePlayingVoice === v.id ? `Stop ${v.id} sample` : `Play ${v.id} sample`}
|
||||
>
|
||||
{#if samplePlayingVoice === v.id}
|
||||
@@ -718,13 +731,13 @@
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="mt-6 p-4 rounded-lg bg-zinc-800 border border-zinc-700">
|
||||
<div class="mt-6 p-4 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
||||
<div class="flex items-center justify-between gap-2 mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-4 h-4 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-zinc-300 font-medium">Audio Narration</span>
|
||||
<span class="text-sm text-(--color-text) font-medium">{m.reader_audio_narration()}</span>
|
||||
</div>
|
||||
|
||||
<!-- Voice selector button -->
|
||||
@@ -733,8 +746,8 @@
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
|
||||
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : '')}
|
||||
title="Change voice"
|
||||
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
|
||||
title={m.reader_change_voice()}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
@@ -749,15 +762,15 @@
|
||||
|
||||
<!-- ── Voice selector panel ──────────────────────────────────────────── -->
|
||||
{#if showVoicePanel && voices.length > 0}
|
||||
<div class="mb-3 rounded-lg border border-zinc-600 bg-zinc-900 overflow-hidden">
|
||||
<div class="px-3 py-2 border-b border-zinc-700 flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Choose Voice</span>
|
||||
<div class="mb-3 rounded-lg border border-(--color-border) bg-(--color-surface) overflow-hidden">
|
||||
<div class="px-3 py-2 border-b border-(--color-border) flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.reader_choose_voice()}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 text-zinc-500 hover:text-zinc-300"
|
||||
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
|
||||
onclick={() => { stopSample(); showVoicePanel = false; }}
|
||||
aria-label="Close voice selector"
|
||||
aria-label={m.reader_close_voice_panel()}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
@@ -767,8 +780,8 @@
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
<!-- Kokoro (GPU) section -->
|
||||
{#if kokoroVoices.length > 0}
|
||||
<div class="px-3 py-1.5 bg-zinc-800/70 border-b border-zinc-700/50">
|
||||
<span class="text-[10px] font-semibold text-zinc-500 uppercase tracking-widest">Kokoro (GPU)</span>
|
||||
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50">
|
||||
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Kokoro (GPU)</span>
|
||||
</div>
|
||||
{#each kokoroVoices as v (v.id)}
|
||||
{@render voiceRow(v)}
|
||||
@@ -777,26 +790,26 @@
|
||||
|
||||
<!-- Pocket TTS (CPU) section -->
|
||||
{#if pocketVoices.length > 0}
|
||||
<div class="px-3 py-1.5 bg-zinc-800/70 border-b border-zinc-700/50 {kokoroVoices.length > 0 ? 'border-t border-zinc-700' : ''}">
|
||||
<span class="text-[10px] font-semibold text-zinc-500 uppercase tracking-widest">Pocket TTS (CPU)</span>
|
||||
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
|
||||
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Pocket TTS (CPU)</span>
|
||||
</div>
|
||||
{#each pocketVoices as v (v.id)}
|
||||
{@render voiceRow(v)}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="px-3 py-2 border-t border-zinc-700 bg-zinc-800/50">
|
||||
<p class="text-xs text-zinc-500">
|
||||
New voice applies on next "Play narration".
|
||||
<div class="px-3 py-2 border-t border-(--color-border) bg-(--color-surface-2)/50">
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
{m.reader_voice_applies_next()}
|
||||
{#if voices.length > 0}
|
||||
<a
|
||||
href="/api/audio/voice-samples"
|
||||
class="text-zinc-400 hover:text-amber-400 transition-colors underline"
|
||||
class="text-(--color-muted) hover:text-(--color-brand) transition-colors underline"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
|
||||
}}
|
||||
>Generate missing samples</a>
|
||||
>{m.reader_generate_samples()}</a>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -808,14 +821,14 @@
|
||||
|
||||
{#if audioStore.status === 'idle' || audioStore.status === 'error'}
|
||||
{#if audioStore.status === 'error'}
|
||||
<p class="text-red-400 text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
|
||||
<p class="text-(--color-danger) text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
|
||||
{/if}
|
||||
<Button variant="default" size="sm" onclick={handlePlay}>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Play narration
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onclick={handlePlay}>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{m.reader_play_narration()}
|
||||
</Button>
|
||||
|
||||
{:else if audioStore.status === 'loading'}
|
||||
<Button variant="default" size="sm" disabled>
|
||||
@@ -823,37 +836,37 @@
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Loading…
|
||||
{m.player_loading()}
|
||||
</Button>
|
||||
|
||||
{:else if audioStore.status === 'generating'}
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs text-zinc-400">Generating narration…</p>
|
||||
<div class="w-full h-1.5 bg-zinc-700 rounded-full overflow-hidden">
|
||||
<p class="text-xs text-(--color-muted)">{m.reader_generating_narration()}</p>
|
||||
<div class="w-full h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-amber-400 rounded-full transition-none"
|
||||
class="h-full bg-(--color-brand) rounded-full transition-none"
|
||||
style="width: {audioStore.progress}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-500 tabular-nums">{Math.round(audioStore.progress)}%</p>
|
||||
<p class="text-xs text-(--color-muted) opacity-60 tabular-nums">{Math.round(audioStore.progress)}%</p>
|
||||
</div>
|
||||
|
||||
{:else if audioStore.status === 'ready'}
|
||||
<!-- Mini-bar is the canonical control surface — show a compact indicator here -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-400">
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-3.5 h-3.5 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
<span>Playing — controls below</span>
|
||||
{:else}
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
<span>Paused — controls below</span>
|
||||
{/if}
|
||||
<span class="tabular-nums text-zinc-500">
|
||||
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-3.5 h-3.5 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
<span>{m.reader_playing()}</span>
|
||||
{:else}
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
<span>{m.reader_paused()}</span>
|
||||
{/if}
|
||||
<span class="tabular-nums text-(--color-muted) opacity-60">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -863,40 +876,40 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('gap-1.5 text-xs flex-shrink-0', audioStore.autoNext ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : 'text-zinc-500')}
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
title={audioStore.autoNext ? `Auto-next on — will play Ch.${nextChapter} automatically` : 'Auto-next off'}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
Auto
|
||||
</Button>
|
||||
class={cn('gap-1.5 text-xs flex-shrink-0', audioStore.autoNext ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
title={audioStore.autoNext ? m.player_auto_next_on() : m.player_auto_next_off()}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
{m.reader_auto_next()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
|
||||
{#if audioStore.autoNext && nextChapter !== null && nextChapter !== undefined}
|
||||
<div class="mt-2">
|
||||
{#if audioStore.nextStatus === 'prefetching'}
|
||||
<div class="flex items-center gap-2 text-xs text-zinc-500">
|
||||
<svg class="w-3 h-3 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span>Preparing Ch.{nextChapter}… {Math.round(audioStore.nextProgress)}%</span>
|
||||
</div>
|
||||
{:else if audioStore.nextStatus === 'prefetched'}
|
||||
<p class="text-xs text-zinc-500 flex items-center gap-1">
|
||||
<svg class="w-3 h-3 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
||||
</svg>
|
||||
Ch.{nextChapter} ready
|
||||
</p>
|
||||
{:else if audioStore.nextStatus === 'failed'}
|
||||
<p class="text-xs text-zinc-600">Ch.{nextChapter} will generate on navigate</p>
|
||||
{/if}
|
||||
{#if audioStore.nextStatus === 'prefetching'}
|
||||
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
<svg class="w-3 h-3 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span>{m.reader_ch_preparing({ n: String(nextChapter), percent: String(Math.round(audioStore.nextProgress)) })}</span>
|
||||
</div>
|
||||
{:else if audioStore.nextStatus === 'prefetched'}
|
||||
<p class="text-xs text-(--color-muted) flex items-center gap-1">
|
||||
<svg class="w-3 h-3 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
||||
</svg>
|
||||
{m.reader_ch_ready({ n: String(nextChapter) })}
|
||||
</p>
|
||||
{:else if audioStore.nextStatus === 'failed'}
|
||||
<p class="text-xs text-(--color-muted) opacity-60">{m.reader_ch_generate_on_nav({ n: String(nextChapter) })}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -904,11 +917,11 @@
|
||||
{:else if audioStore.active}
|
||||
<!-- ── A different chapter is currently playing ── -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-xs text-zinc-400">
|
||||
Now playing: {audioStore.chapterTitle || `Ch.${audioStore.chapter}`}
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
{m.reader_now_playing({ title: audioStore.chapterTitle || `Ch.${audioStore.chapter}` })}
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
|
||||
Load this chapter
|
||||
{m.reader_load_this_chapter()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -918,7 +931,7 @@
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
Play narration
|
||||
{m.reader_play_narration()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -93,14 +93,14 @@
|
||||
render the crop canvas outside the natural image bounds. The fixed
|
||||
height gives cropperjs a stable container to size itself against. -->
|
||||
<div class="px-5">
|
||||
<div class="rounded-xl bg-zinc-800" style="height: 300px; position: relative;">
|
||||
<div class="rounded-xl bg-(--color-surface-2)" style="height: 300px; position: relative;">
|
||||
<img
|
||||
bind:this={imgEl}
|
||||
alt="Crop preview"
|
||||
style="display:block; max-width:100%; max-height:100%;"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-zinc-500 text-center mt-3">
|
||||
<p class="text-xs text-(--color-muted) text-center mt-3">
|
||||
Drag to reposition · pinch or scroll to zoom · drag corners to resize
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { cn } from '$lib/utils';
|
||||
import type { BookComment } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
let {
|
||||
slug,
|
||||
isLoggedIn = false,
|
||||
@@ -243,28 +244,28 @@
|
||||
<div class="mt-10">
|
||||
<!-- Header + sort controls -->
|
||||
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
|
||||
<h2 class="text-base font-semibold text-zinc-200">
|
||||
Comments
|
||||
<h2 class="text-base font-semibold text-(--color-text)">
|
||||
{m.comments_heading()}
|
||||
{#if !loading && totalCount > 0}
|
||||
<span class="text-zinc-500 font-normal text-sm ml-1">({totalCount})</span>
|
||||
<span class="text-(--color-muted) font-normal text-sm ml-1">({totalCount})</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
<!-- Sort tabs -->
|
||||
{#if !loading && comments.length > 0}
|
||||
<div class="flex items-center gap-1 text-xs rounded-lg bg-zinc-800/60 p-1">
|
||||
<div class="flex items-center gap-1 text-xs rounded-lg bg-(--color-surface-2)/60 p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'top' ? 'bg-zinc-700 text-zinc-100 hover:bg-zinc-700' : 'text-zinc-500 hover:text-zinc-300')}
|
||||
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'top' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
onclick={() => (sort = 'top')}
|
||||
>Top</Button>
|
||||
>{m.comments_top()}</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'new' ? 'bg-zinc-700 text-zinc-100 hover:bg-zinc-700' : 'text-zinc-500 hover:text-zinc-300')}
|
||||
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'new' ? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
onclick={() => (sort = 'new')}
|
||||
>New</Button>
|
||||
>{m.comments_new()}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -275,16 +276,16 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<Textarea
|
||||
bind:value={newBody}
|
||||
placeholder="Write a comment…"
|
||||
placeholder={m.comments_placeholder()}
|
||||
rows={3}
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class={cn('text-xs tabular-nums', charOver ? 'text-red-400' : 'text-zinc-600')}>
|
||||
<span class={cn('text-xs tabular-nums', charOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
|
||||
{charCount}/2000
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
{#if postError}
|
||||
<span class="text-xs text-red-400">{postError}</span>
|
||||
<span class="text-xs text-(--color-danger)">{postError}</span>
|
||||
{/if}
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -292,15 +293,15 @@
|
||||
disabled={posting || !newBody.trim() || charOver}
|
||||
onclick={postComment}
|
||||
>
|
||||
{posting ? 'Posting…' : 'Post'}
|
||||
{posting ? m.comments_posting() : m.comments_submit()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-zinc-500">
|
||||
<a href="/login" class="text-amber-400 hover:text-amber-300 transition-colors">Log in</a>
|
||||
to leave a comment.
|
||||
<p class="text-sm text-(--color-muted)">
|
||||
<a href="/login" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">{m.comments_login_link()}</a>
|
||||
{m.comments_login_suffix()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -309,17 +310,17 @@
|
||||
{#if loading}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each Array(3) as _}
|
||||
<div class="rounded-lg bg-zinc-800/50 p-4 animate-pulse">
|
||||
<div class="h-3 w-24 bg-zinc-700 rounded mb-3"></div>
|
||||
<div class="h-3 w-full bg-zinc-700/60 rounded mb-2"></div>
|
||||
<div class="h-3 w-3/4 bg-zinc-700/60 rounded"></div>
|
||||
<div class="rounded-lg bg-(--color-surface-2)/50 p-4 animate-pulse">
|
||||
<div class="h-3 w-24 bg-(--color-surface-3) rounded mb-3"></div>
|
||||
<div class="h-3 w-full bg-(--color-surface-3)/60 rounded mb-2"></div>
|
||||
<div class="h-3 w-3/4 bg-(--color-surface-3)/60 rounded"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<p class="text-sm text-red-400">{loadError}</p>
|
||||
<p class="text-sm text-(--color-danger)">{loadError}</p>
|
||||
{:else if comments.length === 0}
|
||||
<p class="text-sm text-zinc-500">No comments yet. Be the first!</p>
|
||||
<p class="text-sm text-(--color-muted)">{m.comments_empty()}</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each comments as comment (comment.id)}
|
||||
@@ -328,39 +329,39 @@
|
||||
{@const deleting = deletingIds.has(comment.id)}
|
||||
{@const isOwner = isLoggedIn && currentUserId === comment.user_id}
|
||||
|
||||
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if avatarUrls[comment.user_id]}
|
||||
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<div class="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[9px] font-semibold text-zinc-300 leading-none">{initials(comment.username)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if comment.username}
|
||||
<a href="/users/{comment.username}" class="text-sm font-medium text-zinc-200 hover:text-amber-400 transition-colors">{comment.username}</a>
|
||||
<div class="rounded-lg bg-(--color-surface-2)/50 border border-(--color-border)/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if avatarUrls[comment.user_id]}
|
||||
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<span class="text-sm font-medium text-zinc-400">Anonymous</span>
|
||||
<div class="w-6 h-6 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[9px] font-semibold text-(--color-text) leading-none">{initials(comment.username)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="text-zinc-600 text-xs">·</span>
|
||||
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
|
||||
</div>
|
||||
{#if comment.username}
|
||||
<a href="/users/{comment.username}" class="text-sm font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{comment.username}</a>
|
||||
{:else}
|
||||
<span class="text-sm font-medium text-(--color-muted)">{m.comments_anonymous()}</span>
|
||||
{/if}
|
||||
<span class="text-(--color-muted) opacity-60 text-xs">·</span>
|
||||
<span class="text-xs text-(--color-muted)">{formatDate(comment.created)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
|
||||
<!-- Body -->
|
||||
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
|
||||
|
||||
<!-- Actions row: votes + reply + delete -->
|
||||
<div class="flex items-center gap-3 pt-1 flex-wrap">
|
||||
<!-- Upvote -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
|
||||
disabled={voting}
|
||||
onclick={() => vote(comment.id, 'up')}
|
||||
title="Upvote"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
disabled={voting}
|
||||
onclick={() => vote(comment.id, 'up')}
|
||||
title={m.comments_vote_up()}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
|
||||
</svg>
|
||||
@@ -368,14 +369,14 @@
|
||||
</Button>
|
||||
|
||||
<!-- Downvote -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300')}
|
||||
disabled={voting}
|
||||
onclick={() => vote(comment.id, 'down')}
|
||||
title="Downvote"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
disabled={voting}
|
||||
onclick={() => vote(comment.id, 'down')}
|
||||
title={m.comments_vote_down()}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
|
||||
</svg>
|
||||
@@ -384,11 +385,11 @@
|
||||
|
||||
<!-- Reply button -->
|
||||
{#if isLoggedIn}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyingTo === comment.id ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
|
||||
onclick={() => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyingTo === comment.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
onclick={() => {
|
||||
if (replyingTo === comment.id) {
|
||||
replyingTo = null;
|
||||
replyBody = '';
|
||||
@@ -403,57 +404,57 @@
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
|
||||
</svg>
|
||||
Reply
|
||||
{m.comments_reply()}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<!-- Delete (owner only) -->
|
||||
{#if isOwner}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-auto px-1 py-0 gap-1 text-xs text-zinc-600 hover:text-red-400 ml-auto"
|
||||
disabled={deleting}
|
||||
onclick={() => deleteComment(comment.id)}
|
||||
title="Delete comment"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
|
||||
disabled={deleting}
|
||||
onclick={() => deleteComment(comment.id)}
|
||||
title="Delete comment"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
Delete
|
||||
{m.comments_delete()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Inline reply form -->
|
||||
{#if replyingTo === comment.id}
|
||||
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-zinc-700">
|
||||
<Textarea
|
||||
bind:value={replyBody}
|
||||
placeholder="Write a reply…"
|
||||
rows={2}
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class={cn('text-xs tabular-nums', replyCharOver ? 'text-red-400' : 'text-zinc-600')}>
|
||||
{replyCharCount}/2000
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if replyError}
|
||||
<span class="text-xs text-red-400">{replyError}</span>
|
||||
{/if}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-zinc-400 hover:text-zinc-200"
|
||||
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
|
||||
>Cancel</Button>
|
||||
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-(--color-border)">
|
||||
<Textarea
|
||||
bind:value={replyBody}
|
||||
placeholder={m.comments_placeholder()}
|
||||
rows={2}
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class={cn('text-xs tabular-nums', replyCharOver ? 'text-(--color-danger)' : 'text-(--color-muted) opacity-60')}>
|
||||
{replyCharCount}/2000
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if replyError}
|
||||
<span class="text-xs text-(--color-danger)">{replyError}</span>
|
||||
{/if}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-(--color-muted) hover:text-(--color-text)"
|
||||
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
|
||||
>{m.common_cancel()}</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={replyPosting || !replyBody.trim() || replyCharOver}
|
||||
onclick={() => postReply(comment.id)}
|
||||
>
|
||||
{replyPosting ? 'Posting…' : 'Reply'}
|
||||
{replyPosting ? m.comments_posting() : m.comments_reply()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -462,59 +463,59 @@
|
||||
|
||||
<!-- Replies -->
|
||||
{#if comment.replies && comment.replies.length > 0}
|
||||
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-zinc-700/60">
|
||||
{#each comment.replies as reply (reply.id)}
|
||||
{@const replyVote = myVotes[reply.id]}
|
||||
{@const replyVoting = votingIds.has(reply.id)}
|
||||
{@const replyDeleting = deletingIds.has(reply.id)}
|
||||
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
|
||||
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-(--color-border)/60">
|
||||
{#each comment.replies as reply (reply.id)}
|
||||
{@const replyVote = myVotes[reply.id]}
|
||||
{@const replyVoting = votingIds.has(reply.id)}
|
||||
{@const replyDeleting = deletingIds.has(reply.id)}
|
||||
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
|
||||
|
||||
<div class="rounded-md bg-zinc-800/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
|
||||
<!-- Reply header -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if avatarUrls[reply.user_id]}
|
||||
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<div class="w-5 h-5 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[8px] font-semibold text-zinc-300 leading-none">{initials(reply.username)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if reply.username}
|
||||
<a href="/users/{reply.username}" class="text-xs font-medium text-zinc-300 hover:text-amber-400 transition-colors">{reply.username}</a>
|
||||
<div class="rounded-md bg-(--color-surface-2)/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
|
||||
<!-- Reply header -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if avatarUrls[reply.user_id]}
|
||||
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
|
||||
{:else}
|
||||
<span class="text-xs font-medium text-zinc-400">Anonymous</span>
|
||||
<div class="w-5 h-5 rounded-full bg-(--color-surface-3) flex items-center justify-center flex-shrink-0">
|
||||
<span class="text-[8px] font-semibold text-(--color-text) leading-none">{initials(reply.username)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="text-zinc-600 text-xs">·</span>
|
||||
<span class="text-xs text-zinc-500">{formatDate(reply.created)}</span>
|
||||
</div>
|
||||
{#if reply.username}
|
||||
<a href="/users/{reply.username}" class="text-xs font-medium text-(--color-text) hover:text-(--color-brand) transition-colors">{reply.username}</a>
|
||||
{:else}
|
||||
<span class="text-xs font-medium text-(--color-muted)">{m.comments_anonymous()}</span>
|
||||
{/if}
|
||||
<span class="text-(--color-muted) opacity-60 text-xs">·</span>
|
||||
<span class="text-xs text-(--color-muted)">{formatDate(reply.created)}</span>
|
||||
</div>
|
||||
|
||||
<!-- Reply body -->
|
||||
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
|
||||
<!-- Reply body -->
|
||||
<p class="text-sm text-(--color-text) leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
|
||||
|
||||
<!-- Reply actions -->
|
||||
<div class="flex items-center gap-3 pt-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
|
||||
disabled={replyVoting}
|
||||
onclick={() => vote(reply.id, 'up', comment.id)}
|
||||
title="Upvote"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
disabled={replyVoting}
|
||||
onclick={() => vote(reply.id, 'up', comment.id)}
|
||||
title={m.comments_vote_up()}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
|
||||
</svg>
|
||||
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300')}
|
||||
disabled={replyVoting}
|
||||
onclick={() => vote(reply.id, 'down', comment.id)}
|
||||
title="Downvote"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'down' ? 'text-(--color-danger)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
disabled={replyVoting}
|
||||
onclick={() => vote(reply.id, 'down', comment.id)}
|
||||
title={m.comments_vote_down()}
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
|
||||
</svg>
|
||||
@@ -522,19 +523,19 @@
|
||||
</Button>
|
||||
|
||||
{#if replyIsOwner}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-auto px-1 py-0 gap-1 text-xs text-zinc-600 hover:text-red-400 ml-auto"
|
||||
disabled={replyDeleting}
|
||||
onclick={() => deleteComment(reply.id, comment.id)}
|
||||
title="Delete reply"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-auto px-1 py-0 gap-1 text-xs text-(--color-muted) hover:text-(--color-danger) ml-auto"
|
||||
disabled={replyDeleting}
|
||||
onclick={() => deleteComment(reply.id, comment.id)}
|
||||
title="Delete reply"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
{m.comments_delete()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none';
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
default: 'border-transparent bg-amber-400 text-zinc-900',
|
||||
secondary: 'border-transparent bg-zinc-700 text-zinc-200',
|
||||
outline: 'border-zinc-600 text-zinc-300',
|
||||
destructive: 'border-transparent bg-red-500/20 text-red-400',
|
||||
default: 'border-transparent bg-(--color-brand) text-(--color-surface)',
|
||||
secondary: 'border-transparent bg-(--color-surface-3) text-(--color-text)',
|
||||
outline: 'border-(--color-border) text-(--color-muted)',
|
||||
destructive: 'border-transparent bg-(--color-danger)/20 text-(--color-danger)',
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -28,15 +28,15 @@
|
||||
}: Props = $props();
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900 disabled:pointer-events-none disabled:opacity-50';
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-brand) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-surface) disabled:pointer-events-none disabled:opacity-50';
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
default: 'bg-amber-400 text-zinc-900 hover:bg-amber-300',
|
||||
secondary: 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600',
|
||||
outline: 'border border-zinc-600 bg-transparent text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100',
|
||||
ghost: 'bg-transparent text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100',
|
||||
destructive: 'bg-red-500/20 text-red-400 hover:bg-red-500/30 hover:text-red-300',
|
||||
link: 'text-amber-400 underline-offset-4 hover:underline bg-transparent p-0 h-auto',
|
||||
default: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim)',
|
||||
secondary: 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-border)',
|
||||
outline: 'border border-(--color-border) bg-transparent text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)',
|
||||
ghost: 'bg-transparent text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)',
|
||||
destructive: 'bg-(--color-danger)/20 text-(--color-danger) hover:bg-(--color-danger)/30',
|
||||
link: 'text-(--color-brand) underline-offset-4 hover:underline bg-transparent p-0 h-auto',
|
||||
};
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('rounded-xl border border-zinc-700 bg-zinc-800/50', className)}>
|
||||
<div class={cn('rounded-xl border border-(--color-border) bg-(--color-surface-2)/50', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<p class={cn('text-sm text-zinc-400', className)}>
|
||||
<p class={cn('text-sm text-(--color-muted)', className)}>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<h3 class={cn('font-semibold leading-none tracking-tight text-zinc-100', className)}>
|
||||
<h3 class={cn('font-semibold leading-none tracking-tight text-(--color-text)', className)}>
|
||||
{@render children?.()}
|
||||
</h3>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
aria-modal="true"
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<div class={cn('bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl w-full max-w-sm', className)}>
|
||||
<div class={cn('bg-(--color-surface) rounded-2xl border border-(--color-border) shadow-2xl w-full max-w-sm', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,6 @@
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<h2 class={cn('text-base font-semibold leading-none tracking-tight text-zinc-100', className)}>
|
||||
<h2 class={cn('text-base font-semibold leading-none tracking-tight text-(--color-text)', className)}>
|
||||
{@render children?.()}
|
||||
</h2>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div
|
||||
role="separator"
|
||||
class={cn(
|
||||
'shrink-0 bg-zinc-700',
|
||||
'shrink-0 bg-(--color-border)',
|
||||
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
{rows}
|
||||
{disabled}
|
||||
class={cn(
|
||||
'flex w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 resize-none transition-colors',
|
||||
'focus:outline-none focus:border-amber-400',
|
||||
'flex w-full rounded-lg border border-(--color-border) bg-(--color-surface-2) px-3 py-2 text-sm text-(--color-text) placeholder-zinc-500 resize-none transition-colors',
|
||||
'focus:outline-none focus:border-(--color-brand)',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -40,8 +40,8 @@ export async function presignAvatarUploadUrl(userId: string, mimeType: string):
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a presigned GET URL for a user's avatar, rewritten to the public URL.
|
||||
* Returns null if no avatar exists.
|
||||
* Returns a presigned GET URL for a user's avatar from MinIO.
|
||||
* Returns null if no avatar object exists in MinIO for this user.
|
||||
*/
|
||||
export async function presignAvatarUrl(userId: string): Promise<string | null> {
|
||||
const res = await backendFetch(`/api/presign/avatar/${encodeURIComponent(userId)}`);
|
||||
@@ -54,6 +54,42 @@ export async function presignAvatarUrl(userId: string): Promise<string | null> {
|
||||
return data.url ? rewriteHost(data.url) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the best available avatar URL for a user.
|
||||
*
|
||||
* Priority:
|
||||
* 1. MinIO — if the user has uploaded a custom avatar it will be found here
|
||||
* (presigned, short-lived GET URL).
|
||||
* 2. OAuth provider URL — stored in avatar_url when the account was created
|
||||
* via Google / GitHub OAuth (e.g. https://lh3.googleusercontent.com/...).
|
||||
* Returned as-is; the browser fetches it directly.
|
||||
*
|
||||
* Pass the raw `avatar_url` field from the PocketBase record as `storedValue`
|
||||
* so this function can distinguish between a MinIO key and a remote URL without
|
||||
* an extra DB round-trip.
|
||||
*
|
||||
* Returns null when neither source yields an avatar.
|
||||
*/
|
||||
export async function resolveAvatarUrl(
|
||||
userId: string,
|
||||
storedValue: string | null | undefined
|
||||
): Promise<string | null> {
|
||||
// 1. Try MinIO first (custom upload takes priority over OAuth picture).
|
||||
try {
|
||||
const minioUrl = await presignAvatarUrl(userId);
|
||||
if (minioUrl) return minioUrl;
|
||||
} catch {
|
||||
// MinIO unavailable — fall through to OAuth fallback.
|
||||
}
|
||||
|
||||
// 2. Fall back to OAuth-provided picture URL if it looks like a remote URL.
|
||||
if (storedValue && storedValue.startsWith('http')) {
|
||||
return storedValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the MinIO host in a presigned URL to the public-facing URL.
|
||||
*
|
||||
|
||||
@@ -54,6 +54,10 @@ export interface PBUserSettings {
|
||||
auto_next: boolean;
|
||||
voice: string;
|
||||
speed: number;
|
||||
theme?: string;
|
||||
locale?: string;
|
||||
font_family?: string;
|
||||
font_size?: number;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
@@ -70,6 +74,8 @@ export interface User {
|
||||
verification_token_exp?: string;
|
||||
oauth_provider?: string;
|
||||
oauth_id?: string;
|
||||
polar_customer_id?: string;
|
||||
polar_subscription_id?: string;
|
||||
}
|
||||
|
||||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||||
@@ -191,8 +197,8 @@ async function countCollection(collection: string, filter = ''): Promise<number>
|
||||
return (data as { totalItems: number }).totalItems ?? 0;
|
||||
}
|
||||
|
||||
async function listOne<T>(collection: string, filter: string): Promise<T | null> {
|
||||
const params = new URLSearchParams({ perPage: '1', filter });
|
||||
async function listOne<T>(collection: string, filter: string, sort = '-updated'): Promise<T | null> {
|
||||
const params = new URLSearchParams({ perPage: '1', filter, sort });
|
||||
const data = await pbGet<PBList<T>>(
|
||||
`/api/collections/${collection}/records?${params.toString()}`
|
||||
);
|
||||
@@ -541,6 +547,19 @@ export async function getUserByUsername(username: string): Promise<User | null>
|
||||
return listOne<User>('app_users', `username="${username.replace(/"/g, '\\"')}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a user by their PocketBase record ID. Returns null if not found.
|
||||
*/
|
||||
export async function getUserById(id: string): Promise<User | null> {
|
||||
const token = await getToken();
|
||||
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${encodeURIComponent(id)}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Promise<User>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a user by email. Returns null if not found.
|
||||
*/
|
||||
@@ -558,6 +577,28 @@ export async function getUserByOAuth(provider: string, oauthId: string): Promise
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a user by their Polar customer ID. Returns null if not found.
|
||||
*/
|
||||
export async function getUserByPolarCustomerId(polarCustomerId: string): Promise<User | null> {
|
||||
return listOne<User>(
|
||||
'app_users',
|
||||
`polar_customer_id="${polarCustomerId.replace(/"/g, '\\"')}"`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch arbitrary fields on an app_user record.
|
||||
*/
|
||||
export async function patchUser(userId: string, fields: Partial<User & Record<string, unknown>>): Promise<void> {
|
||||
const res = await pbPatch(`/api/collections/app_users/records/${encodeURIComponent(userId)}`, fields);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'patchUser failed', { userId, status: res.status, body });
|
||||
throw new Error(`patchUser failed: ${res.status} — ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user via OAuth (no password). email_verified is true since the
|
||||
* provider already verified it. Throws on DB errors.
|
||||
@@ -765,7 +806,7 @@ export async function getSettings(
|
||||
|
||||
export async function saveSettings(
|
||||
sessionId: string,
|
||||
settings: { autoNext: boolean; voice: string; speed: number },
|
||||
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number },
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const existing = await listOne<PBUserSettings & { id: string }>(
|
||||
@@ -780,6 +821,10 @@ export async function saveSettings(
|
||||
speed: settings.speed,
|
||||
updated: new Date().toISOString()
|
||||
};
|
||||
if (settings.theme !== undefined) payload.theme = settings.theme;
|
||||
if (settings.locale !== undefined) payload.locale = settings.locale;
|
||||
if (settings.fontFamily !== undefined) payload.font_family = settings.fontFamily;
|
||||
if (settings.fontSize !== undefined) payload.font_size = settings.fontSize;
|
||||
if (userId) payload.user_id = userId;
|
||||
|
||||
if (existing) {
|
||||
@@ -902,6 +947,24 @@ export async function listAudioJobs(): Promise<AudioJob[]> {
|
||||
return listAll<AudioJob>('audio_jobs', '', '-started');
|
||||
}
|
||||
|
||||
// ─── Translation jobs ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface TranslationJob {
|
||||
id: string;
|
||||
cache_key: string; // "slug/chapter/lang"
|
||||
slug: string;
|
||||
chapter: number;
|
||||
lang: string;
|
||||
status: string; // "pending" | "running" | "done" | "failed"
|
||||
error_message: string;
|
||||
started: string;
|
||||
finished: string;
|
||||
}
|
||||
|
||||
export async function listTranslationJobs(): Promise<TranslationJob[]> {
|
||||
return listAll<TranslationJob>('translation_jobs', '', '-started');
|
||||
}
|
||||
|
||||
export async function getAudioTime(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
@@ -949,6 +1012,8 @@ export async function createUserSession(
|
||||
throw new Error(`Failed to create session: ${res.status}`);
|
||||
}
|
||||
const rec = (await res.json()) as { id: string };
|
||||
// Best-effort: prune stale sessions in the background so the list doesn't grow forever
|
||||
pruneStaleUserSessions(userId).catch(() => {});
|
||||
return rec.id;
|
||||
}
|
||||
|
||||
@@ -985,6 +1050,28 @@ export async function listUserSessions(userId: string): Promise<UserSession[]> {
|
||||
return listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete sessions for a user that haven't been seen in the last `days` days.
|
||||
* Called on login so the list self-cleans without a separate cron job.
|
||||
*/
|
||||
async function pruneStaleUserSessions(userId: string, days = 30): Promise<void> {
|
||||
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
||||
const stale = await listAll<UserSession>(
|
||||
'user_sessions',
|
||||
`user_id="${userId}" && last_seen<"${cutoff}"`
|
||||
);
|
||||
if (stale.length === 0) return;
|
||||
const token = await getToken();
|
||||
await Promise.all(
|
||||
stale.map((s) =>
|
||||
fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).catch(() => {})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) a specific session by its PocketBase record ID.
|
||||
* Only allows deletion if the session belongs to the given userId.
|
||||
|
||||
107
ui/src/lib/server/polar.ts
Normal file
107
ui/src/lib/server/polar.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Polar.sh integration — server-side only.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Verify webhook signatures (HMAC-SHA256)
|
||||
* - Patch app_users.polar_customer_id / polar_subscription_id / role on subscription events
|
||||
* - Expose isPro(userId) helper for gating
|
||||
*
|
||||
* Product IDs (Polar dashboard):
|
||||
* Monthly : 1376fdf5-b6a9-492b-be70-7c905131c0f9
|
||||
* Annual : b6190307-79aa-4905-80c8-9ed941378d21
|
||||
*/
|
||||
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { getUserById, getUserByPolarCustomerId, patchUser } from '$lib/server/pocketbase';
|
||||
|
||||
export const POLAR_PRO_PRODUCT_IDS = new Set([
|
||||
'1376fdf5-b6a9-492b-be70-7c905131c0f9', // monthly
|
||||
'b6190307-79aa-4905-80c8-9ed941378d21' // annual
|
||||
]);
|
||||
|
||||
// ─── Webhook signature verification ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify the Polar webhook signature.
|
||||
* Polar signs with HMAC-SHA256 over the raw body; header is "webhook-signature".
|
||||
* Header format: "v1=<hex>" (may be comma-separated list of sigs)
|
||||
*/
|
||||
export function verifyPolarWebhook(rawBody: string, signatureHeader: string): boolean {
|
||||
const secret = env.POLAR_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
log.warn('polar', 'POLAR_WEBHOOK_SECRET not set — rejecting webhook');
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
const expectedBuf = Buffer.from(`v1=${expected}`);
|
||||
|
||||
// Header may contain multiple sigs separated by ", "
|
||||
const sigs = signatureHeader.split(',').map((s) => s.trim());
|
||||
for (const sig of sigs) {
|
||||
try {
|
||||
const sigBuf = Buffer.from(sig);
|
||||
if (sigBuf.length === expectedBuf.length && timingSafeEqual(sigBuf, expectedBuf)) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// length mismatch etc — try next
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Subscription event handler ───────────────────────────────────────────────
|
||||
|
||||
interface PolarSubscription {
|
||||
id: string;
|
||||
status: string; // "active" | "canceled" | "past_due" | "unpaid" | "incomplete" | ...
|
||||
product_id: string;
|
||||
customer_id: string;
|
||||
customer_email?: string;
|
||||
user_id?: string; // Polar user id (not our user id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a Polar subscription event.
|
||||
* Finds the matching app_user by email and updates role + polar fields.
|
||||
*/
|
||||
export async function handleSubscriptionEvent(
|
||||
eventType: string,
|
||||
subscription: PolarSubscription
|
||||
): Promise<void> {
|
||||
const { id: subId, status, product_id, customer_id, customer_email } = subscription;
|
||||
|
||||
log.info('polar', 'subscription event', { eventType, subId, status, product_id, customer_email });
|
||||
|
||||
if (!customer_email) {
|
||||
log.warn('polar', 'subscription event missing customer_email — cannot match user', { subId });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find user by their polar_customer_id first (faster on repeat events), then by email
|
||||
let user = await getUserByPolarCustomerId(customer_id).catch(() => null);
|
||||
if (!user) {
|
||||
const { getUserByEmail } = await import('$lib/server/pocketbase');
|
||||
user = await getUserByEmail(customer_email).catch(() => null);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
log.warn('polar', 'no app_user found for polar customer', { customer_email, customer_id });
|
||||
return;
|
||||
}
|
||||
|
||||
const isProProduct = POLAR_PRO_PRODUCT_IDS.has(product_id);
|
||||
const isActive = status === 'active';
|
||||
const newRole = isProProduct && isActive ? 'pro' : (user.role === 'admin' ? 'admin' : 'user');
|
||||
|
||||
await patchUser(user.id, {
|
||||
role: newRole,
|
||||
polar_customer_id: customer_id,
|
||||
polar_subscription_id: isActive ? subId : ''
|
||||
});
|
||||
|
||||
log.info('polar', 'user role updated', { userId: user.id, username: user.username, newRole, status });
|
||||
}
|
||||
@@ -41,4 +41,5 @@ export interface UserSettings {
|
||||
voice: string;
|
||||
speed: number;
|
||||
autoNext: boolean;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
@@ -1,71 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
const status = $derived(page.status);
|
||||
const message = $derived(page.error?.message ?? 'Something went wrong.');
|
||||
|
||||
const title = $derived(
|
||||
status === 404
|
||||
? 'Page not found'
|
||||
: status === 403
|
||||
? 'Access denied'
|
||||
: status === 429
|
||||
? 'Too many requests'
|
||||
: status >= 500
|
||||
? 'Server error'
|
||||
: 'Error'
|
||||
? m.error_not_found_title()
|
||||
: m.error_generic_title()
|
||||
);
|
||||
|
||||
const description = $derived(
|
||||
status === 404
|
||||
? "The page you're looking for doesn't exist or has been moved."
|
||||
: status === 403
|
||||
? "You don't have permission to access this page."
|
||||
: status === 429
|
||||
? 'You are sending too many requests. Please slow down and try again shortly.'
|
||||
: status >= 500
|
||||
? 'An unexpected error occurred on our end. Try refreshing, or come back in a moment.'
|
||||
: message
|
||||
? m.error_not_found_body()
|
||||
: page.error?.message ?? m.error_generic_title()
|
||||
);
|
||||
|
||||
const code = $derived(String(status));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{status} — {title} · libnovel</title>
|
||||
<title>{m.error_status({ status: code })} · libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Full-viewport centred error page — no layout nav since this is +error.svelte -->
|
||||
<div
|
||||
class="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col items-center justify-center px-6 py-16 font-sans"
|
||||
class="min-h-screen bg-(--color-surface) text-(--color-text) flex flex-col items-center justify-center px-6 py-16 font-sans"
|
||||
>
|
||||
<!-- Large status code -->
|
||||
<p class="text-[8rem] sm:text-[11rem] font-black leading-none text-zinc-800 select-none tabular-nums">
|
||||
<p class="text-[8rem] sm:text-[11rem] font-black leading-none bg-(--color-surface-2) select-none tabular-nums">
|
||||
{code}
|
||||
</p>
|
||||
|
||||
<!-- Title + description -->
|
||||
<div class="mt-4 text-center max-w-md space-y-2">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-zinc-100">{title}</h1>
|
||||
<p class="text-zinc-400 text-sm sm:text-base leading-relaxed">{description}</p>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-(--color-text)">{title}</h1>
|
||||
<p class="text-(--color-muted) text-sm sm:text-base leading-relaxed">{description}</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-10 flex flex-wrap gap-3 justify-center">
|
||||
<a
|
||||
href="/"
|
||||
class="px-5 py-2.5 rounded-xl bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
|
||||
class="px-5 py-2.5 rounded-xl bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Go home
|
||||
{m.error_go_home()}
|
||||
</a>
|
||||
<button
|
||||
onclick={() => history.back()}
|
||||
class="px-5 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-200 font-semibold text-sm hover:bg-zinc-700 transition-colors"
|
||||
class="px-5 py-2.5 rounded-xl bg-(--color-surface-2) border border-(--color-border) text-(--color-text) font-semibold text-sm hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
Go back
|
||||
{m.common_back()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Subtle branding -->
|
||||
<p class="mt-16 text-xs text-zinc-700 tracking-widest uppercase select-none">libnovel</p>
|
||||
<p class="mt-16 text-xs text-(--color-muted) tracking-widest uppercase select-none">libnovel</p>
|
||||
</div>
|
||||
|
||||
@@ -4,31 +4,60 @@ import { getSettings } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
// Routes that are accessible without being logged in
|
||||
const PUBLIC_ROUTES = new Set(['/login', '/disclaimer', '/privacy', '/dmca', '/terms']);
|
||||
const PUBLIC_ROUTES = new Set(['/login', '/disclaimer', '/privacy', '/dmca', '/terms', '/catalogue']);
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals, url }) => {
|
||||
// Allow /auth/* (OAuth initiation + callbacks) without login
|
||||
const isPublic = PUBLIC_ROUTES.has(url.pathname) || url.pathname.startsWith('/auth/');
|
||||
export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
||||
// Allow public routes, /auth/*, and all book-browsing URLs (/books/[slug] and deeper)
|
||||
// Note: /books (the personal library) is intentionally NOT public
|
||||
const isPublic =
|
||||
PUBLIC_ROUTES.has(url.pathname) ||
|
||||
url.pathname.startsWith('/auth/') ||
|
||||
url.pathname.startsWith('/books/');
|
||||
if (!isPublic && !locals.user) {
|
||||
redirect(302, `/login`);
|
||||
}
|
||||
|
||||
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0 };
|
||||
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0 };
|
||||
try {
|
||||
const row = await getSettings(locals.sessionId, locals.user?.id);
|
||||
if (row) {
|
||||
settings = {
|
||||
autoNext: row.auto_next ?? false,
|
||||
voice: row.voice ?? 'af_bella',
|
||||
speed: row.speed ?? 1.0
|
||||
speed: row.speed ?? 1.0,
|
||||
theme: row.theme ?? 'amber',
|
||||
locale: row.locale ?? 'en',
|
||||
fontFamily: row.font_family ?? 'system',
|
||||
fontSize: row.font_size ?? 1.0
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('layout', 'failed to load settings', { err: String(e) });
|
||||
}
|
||||
|
||||
// If user is logged in, keep the PARAGLIDE_LOCALE cookie in sync with
|
||||
// the saved locale so it persists across page loads and navigations.
|
||||
if (locals.user) {
|
||||
const savedLocale = settings.locale ?? 'en';
|
||||
const currentCookieLocale = cookies.get('PARAGLIDE_LOCALE');
|
||||
if (savedLocale === 'en') {
|
||||
// Clear the cookie when the user's locale is English (the default)
|
||||
if (currentCookieLocale) {
|
||||
cookies.delete('PARAGLIDE_LOCALE', { path: '/' });
|
||||
}
|
||||
} else if (currentCookieLocale !== savedLocale) {
|
||||
cookies.set('PARAGLIDE_LOCALE', savedLocale, {
|
||||
path: '/',
|
||||
maxAge: 34560000,
|
||||
sameSite: 'lax',
|
||||
httpOnly: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
isPro: locals.isPro,
|
||||
settings
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,18 +2,34 @@
|
||||
import '../app.css';
|
||||
import { page, navigating } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { setContext } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/utils';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { locales, getLocale } from '$lib/paraglide/runtime.js';
|
||||
|
||||
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||
|
||||
// Mobile nav drawer state
|
||||
let menuOpen = $state(false);
|
||||
|
||||
// Desktop dropdown menus
|
||||
let userMenuOpen = $state(false);
|
||||
let langMenuOpen = $state(false);
|
||||
|
||||
const THEMES = [
|
||||
{ id: 'amber', color: '#f59e0b' },
|
||||
{ id: 'slate', color: '#818cf8' },
|
||||
{ id: 'rose', color: '#fb7185' },
|
||||
{ id: 'light', color: '#d97706', light: true },
|
||||
{ id: 'light-slate', color: '#4f46e5', light: true },
|
||||
{ id: 'light-rose', color: '#e11d48', light: true },
|
||||
];
|
||||
|
||||
// Chapter list drawer state for the mini-player
|
||||
let chapterDrawerOpen = $state(false);
|
||||
|
||||
@@ -21,24 +37,66 @@
|
||||
// AudioPlayer components in chapter pages control it via audioStore.
|
||||
let audioEl = $state<HTMLAudioElement | null>(null);
|
||||
|
||||
// ── Theme ──────────────────────────────────────────────────────────────
|
||||
let currentTheme = $state(data.settings?.theme ?? 'amber');
|
||||
let currentFontFamily = $state(data.settings?.fontFamily ?? 'system');
|
||||
let currentFontSize = $state(data.settings?.fontSize ?? 1.0);
|
||||
|
||||
// Expose theme + font state to child pages (e.g. profile picker)
|
||||
setContext('theme', {
|
||||
get current() { return currentTheme; },
|
||||
set current(v: string) { currentTheme = v; },
|
||||
get fontFamily() { return currentFontFamily; },
|
||||
set fontFamily(v: string) { currentFontFamily = v; },
|
||||
get fontSize() { return currentFontSize; },
|
||||
set fontSize(v: number) { currentFontSize = v; }
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-theme', currentTheme);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const fontMap: Record<string, string> = {
|
||||
system: 'system-ui, -apple-system, sans-serif',
|
||||
serif: "Georgia, 'Times New Roman', serif",
|
||||
mono: "'Courier New', monospace",
|
||||
};
|
||||
document.documentElement.style.setProperty('--reading-font', fontMap[currentFontFamily] ?? fontMap.system);
|
||||
document.documentElement.style.setProperty('--reading-size', `${currentFontSize}rem`);
|
||||
});
|
||||
|
||||
// Apply persisted settings once on mount (server-loaded data).
|
||||
// Use a derived to react to future invalidateAll() re-loads too.
|
||||
let settingsApplied = false;
|
||||
$effect(() => {
|
||||
if (!settingsApplied && data.settings) {
|
||||
settingsApplied = true;
|
||||
audioStore.autoNext = data.settings.autoNext;
|
||||
audioStore.voice = data.settings.voice;
|
||||
audioStore.speed = data.settings.speed;
|
||||
if (data.settings) {
|
||||
if (!settingsApplied) {
|
||||
settingsApplied = true;
|
||||
audioStore.autoNext = data.settings.autoNext;
|
||||
audioStore.voice = data.settings.voice;
|
||||
audioStore.speed = data.settings.speed;
|
||||
}
|
||||
// Always sync theme + font (profile page calls invalidateAll after saving)
|
||||
currentTheme = data.settings.theme ?? 'amber';
|
||||
currentFontFamily = data.settings.fontFamily ?? 'system';
|
||||
currentFontSize = data.settings.fontSize ?? 1.0;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Persist settings changes (debounced 800ms) ──────────────────────────
|
||||
let settingsSaveTimer = 0;
|
||||
$effect(() => {
|
||||
// Subscribe to the three settings fields
|
||||
// Subscribe to settings fields
|
||||
const autoNext = audioStore.autoNext;
|
||||
const voice = audioStore.voice;
|
||||
const speed = audioStore.speed;
|
||||
const theme = currentTheme;
|
||||
const fontFamily = currentFontFamily;
|
||||
const fontSize = currentFontSize;
|
||||
|
||||
// Skip saving until settings have been applied from the server
|
||||
if (!settingsApplied) return;
|
||||
@@ -48,7 +106,7 @@
|
||||
fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoNext, voice, speed })
|
||||
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize })
|
||||
}).catch(() => {});
|
||||
}, 800) as unknown as number;
|
||||
});
|
||||
@@ -144,7 +202,7 @@
|
||||
audioEl.currentTime = Math.min(audioEl.duration || 0, audioEl.currentTime + 30);
|
||||
}
|
||||
|
||||
const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
|
||||
const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0];
|
||||
|
||||
function cycleSpeed() {
|
||||
const idx = speedSteps.indexOf(audioStore.speed);
|
||||
@@ -170,6 +228,9 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>libnovel</title>
|
||||
<!-- Apply theme before first paint to avoid flash -->
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html `<script>document.documentElement.setAttribute('data-theme','${data.settings?.theme ?? 'amber'}')</script>`}
|
||||
<!-- Umami analytics — no-op when PUBLIC_UMAMI_WEBSITE_ID is unset -->
|
||||
{#if env.PUBLIC_UMAMI_WEBSITE_ID && env.PUBLIC_UMAMI_SCRIPT_URL}
|
||||
<script
|
||||
@@ -216,18 +277,18 @@
|
||||
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active}>
|
||||
<!-- Navigation progress bar — shown while SSR is running for any page transition -->
|
||||
{#if navigating}
|
||||
<div class="fixed top-0 left-0 right-0 z-[100] h-1 bg-zinc-800">
|
||||
<div class="h-full bg-amber-400 animate-progress-bar"></div>
|
||||
<div class="fixed top-0 left-0 right-0 z-[100] h-1 bg-(--color-surface-2)">
|
||||
<div class="h-full bg-(--color-brand) animate-progress-bar"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<header class="border-b border-zinc-700 bg-zinc-900 sticky top-0 z-50">
|
||||
<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-amber-400 font-bold text-lg tracking-tight hover:text-amber-300 shrink-0">
|
||||
<a href="/" class="text-(--color-brand) font-bold text-lg tracking-tight hover:text-(--color-brand-dim) shrink-0">
|
||||
libnovel
|
||||
</a>
|
||||
|
||||
{#if page.data.book?.title && /\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
|
||||
<span class="text-zinc-400 text-sm truncate min-w-0 flex-1 sm:flex-none sm:max-w-xs">
|
||||
<span class="text-(--color-muted) text-sm truncate min-w-0 flex-1 sm:flex-none sm:max-w-xs">
|
||||
{page.data.book.title}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -235,83 +296,161 @@
|
||||
{#if data.user}
|
||||
<!-- Desktop nav links (hidden on mobile) -->
|
||||
<a
|
||||
href="/books"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/books') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
|
||||
href="/books"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/books') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Library
|
||||
{m.nav_library()}
|
||||
</a>
|
||||
<a
|
||||
href="/catalogue"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Catalogue
|
||||
{m.nav_catalogue()}
|
||||
</a>
|
||||
<a
|
||||
href="https://feedback.libnovel.cc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hidden sm:block text-sm transition-colors text-zinc-400 hover:text-zinc-100"
|
||||
class="hidden sm:block text-sm transition-colors text-(--color-muted) hover:text-(--color-text)"
|
||||
>
|
||||
Feedback
|
||||
{m.nav_feedback()}
|
||||
</a>
|
||||
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
<!-- Desktop: admin + profile + sign out (hidden on mobile) -->
|
||||
{#if data.user?.role === 'admin'}
|
||||
<a
|
||||
href="/admin/scrape"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin/scrape') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<!-- Theme dots (desktop) -->
|
||||
<div class="hidden sm:flex items-center gap-1 mr-1">
|
||||
{#each THEMES as t, i}
|
||||
{#if i === 3}
|
||||
<span class="w-px h-3 bg-(--color-border) mx-0.5"></span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { currentTheme = t.id; }}
|
||||
title={t.id}
|
||||
class="w-3.5 h-3.5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : t.light ? 'border-(--color-border) opacity-70 hover:opacity-100' : 'border-transparent opacity-50 hover:opacity-100'}"
|
||||
style="background: {t.color};"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Language dropdown (desktop) -->
|
||||
<div class="hidden sm:block relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { langMenuOpen = !langMenuOpen; userMenuOpen = false; }}
|
||||
class="flex items-center gap-1 px-2 py-1 rounded text-xs font-mono transition-colors {langMenuOpen ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
Scrape
|
||||
</a>
|
||||
<a
|
||||
href="/admin/audio"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin/audio') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
|
||||
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
||||
</svg>
|
||||
{getLocale().toUpperCase()}
|
||||
<svg class="w-3 h-3 shrink-0 transition-transform {langMenuOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if langMenuOpen}
|
||||
<div class="absolute right-0 top-full mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl py-1 z-50 min-w-[80px]">
|
||||
{#each locales as locale}
|
||||
<button
|
||||
type="button"
|
||||
onclick={async () => {
|
||||
langMenuOpen = false;
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoNext: audioStore.autoNext, voice: audioStore.voice, speed: audioStore.speed, theme: currentTheme, fontFamily: currentFontFamily, fontSize: currentFontSize, locale })
|
||||
}).catch(() => {});
|
||||
const { setLocale } = await import('$lib/paraglide/runtime.js');
|
||||
setLocale(locale as any, { reload: true });
|
||||
}}
|
||||
class="w-full text-left px-3 py-1.5 text-xs font-mono transition-colors {getLocale() === locale ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'}"
|
||||
>
|
||||
{locale.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- User menu dropdown (desktop) -->
|
||||
<div class="hidden sm:block relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { userMenuOpen = !userMenuOpen; langMenuOpen = false; }}
|
||||
class="flex items-center gap-1.5 pl-1.5 pr-2 py-1 rounded transition-colors {userMenuOpen ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)'}"
|
||||
>
|
||||
Audio
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
href="/profile"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname === '/profile' ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
|
||||
>
|
||||
{data.user.username}
|
||||
</a>
|
||||
<form method="POST" action="/logout" class="hidden sm:block">
|
||||
<Button type="submit" variant="ghost" size="sm" class="text-zinc-400 hover:text-zinc-100">
|
||||
Sign out
|
||||
</Button>
|
||||
</form>
|
||||
<span class="w-6 h-6 rounded-full bg-(--color-brand)/20 text-(--color-brand) text-xs font-bold flex items-center justify-center shrink-0">
|
||||
{data.user.username[0].toUpperCase()}
|
||||
</span>
|
||||
<svg class="w-3 h-3 text-(--color-muted) transition-transform {userMenuOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
{#if userMenuOpen}
|
||||
<div class="absolute right-0 top-full mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl py-1 z-50 min-w-[170px]">
|
||||
<a
|
||||
href="/profile"
|
||||
onclick={() => { userMenuOpen = false; }}
|
||||
class="flex items-center justify-between gap-2 px-3 py-2 text-sm transition-colors {page.url.pathname === '/profile' ? 'text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'}"
|
||||
>
|
||||
{m.nav_profile()}
|
||||
<span class="text-xs opacity-40 truncate max-w-[80px]">{data.user.username}</span>
|
||||
</a>
|
||||
{#if data.user?.role === 'admin'}
|
||||
<a
|
||||
href="/admin/scrape"
|
||||
onclick={() => { userMenuOpen = false; }}
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm transition-colors {page.url.pathname.startsWith('/admin') ? 'text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'}"
|
||||
>
|
||||
{m.nav_admin_panel()}
|
||||
</a>
|
||||
{/if}
|
||||
<div class="my-1 border-t border-(--color-border)/60"></div>
|
||||
<form method="POST" action="/logout">
|
||||
<button type="submit" class="w-full text-left px-3 py-2 text-sm text-(--color-danger) hover:bg-(--color-surface-3) transition-colors">
|
||||
{m.nav_sign_out()}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Mobile: hamburger button -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
aria-label={m.nav_toggle_menu()}
|
||||
aria-expanded={menuOpen}
|
||||
class="sm:hidden -mr-1"
|
||||
>
|
||||
{#if menuOpen}
|
||||
<!-- X icon -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Hamburger icon -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Click-outside overlay for dropdowns -->
|
||||
{#if langMenuOpen || userMenuOpen}
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onpointerdown={() => { langMenuOpen = false; userMenuOpen = false; }}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="ml-auto">
|
||||
<a
|
||||
href="/login"
|
||||
class="text-sm px-3 py-1.5 rounded bg-amber-400 text-zinc-900 font-semibold hover:bg-amber-300 transition-colors"
|
||||
class="text-sm px-3 py-1.5 rounded bg-(--color-brand) text-(--color-surface) font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Sign in
|
||||
{m.nav_sign_in()}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -319,70 +458,101 @@
|
||||
|
||||
<!-- Mobile drawer (full-width, below the bar) -->
|
||||
{#if data.user && menuOpen}
|
||||
<div class="sm:hidden border-t border-zinc-700 bg-zinc-900 px-4 py-3 flex flex-col gap-1">
|
||||
<div class="sm:hidden border-t border-(--color-border) bg-(--color-surface) px-4 py-3 flex flex-col gap-1">
|
||||
<a
|
||||
href="/books"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/books') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/books') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
Library
|
||||
{m.nav_library()}
|
||||
</a>
|
||||
<a
|
||||
href="/catalogue"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/catalogue') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/catalogue') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
Catalogue
|
||||
{m.nav_catalogue()}
|
||||
</a>
|
||||
<a
|
||||
href="https://feedback.libnovel.cc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)"
|
||||
>
|
||||
Feedback ↗
|
||||
{m.nav_feedback()} ↗
|
||||
</a>
|
||||
<a
|
||||
href="/profile"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/profile' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/profile' ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
Profile <span class="text-zinc-500 font-normal">({data.user.username})</span>
|
||||
{m.nav_profile()} <span class="text-(--color-muted) font-normal opacity-60">({data.user.username})</span>
|
||||
</a>
|
||||
{#if data.user?.role === 'admin'}
|
||||
<div class="my-1 border-t border-zinc-700/60"></div>
|
||||
<p class="px-3 pt-1 pb-0.5 text-xs text-zinc-600 uppercase tracking-widest">Admin</p>
|
||||
<a
|
||||
href="/admin/scrape"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin/scrape') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
|
||||
>
|
||||
Scrape tasks
|
||||
</a>
|
||||
<a
|
||||
href="/admin/audio"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/admin/audio' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
|
||||
>
|
||||
Audio cache
|
||||
</a>
|
||||
<a
|
||||
href="/admin/audio-jobs"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin/audio-jobs') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
|
||||
>
|
||||
Audio jobs
|
||||
</a>
|
||||
{/if}
|
||||
<div class="my-1 border-t border-zinc-700/60"></div>
|
||||
{#if data.user?.role === 'admin'}
|
||||
<div class="my-1 border-t border-(--color-border)/60"></div>
|
||||
<p class="px-3 pt-1 pb-0.5 text-xs text-(--color-muted) opacity-50 uppercase tracking-widest">{m.nav_admin()}</p>
|
||||
<a
|
||||
href="/admin/scrape"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
{m.nav_admin_panel()}
|
||||
</a>
|
||||
{/if}
|
||||
<!-- Theme switcher -->
|
||||
<div class="my-1 border-t border-(--color-border)/60"></div>
|
||||
<div class="px-3 py-2.5 flex items-center justify-between">
|
||||
<span class="text-xs text-(--color-muted) uppercase tracking-widest">{m.profile_theme_label()}</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#each THEMES as t, i}
|
||||
{#if i === 3}
|
||||
<span class="w-px h-4 bg-(--color-border) mx-0.5"></span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { currentTheme = t.id; }}
|
||||
title={t.id}
|
||||
class="w-5 h-5 rounded-full border-2 transition-all {currentTheme === t.id ? 'border-(--color-text) scale-110' : t.light ? 'border-(--color-border) opacity-70 hover:opacity-100' : 'border-transparent opacity-50 hover:opacity-100'}"
|
||||
style="background: {t.color};"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Language switcher -->
|
||||
<div class="px-3 py-2.5 flex items-center justify-between">
|
||||
<span class="text-xs text-(--color-muted) uppercase tracking-widest">{m.locale_switcher_label()}</span>
|
||||
<div class="flex items-center gap-0.5">
|
||||
{#each locales as locale}
|
||||
<button
|
||||
type="button"
|
||||
onclick={async () => {
|
||||
menuOpen = false;
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoNext: audioStore.autoNext, voice: audioStore.voice, speed: audioStore.speed, theme: currentTheme, fontFamily: currentFontFamily, fontSize: currentFontSize, locale })
|
||||
}).catch(() => {});
|
||||
const { setLocale } = await import('$lib/paraglide/runtime.js');
|
||||
setLocale(locale as any, { reload: true });
|
||||
}}
|
||||
class="px-1.5 py-0.5 rounded text-xs font-mono transition-colors {getLocale() === locale ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
{locale.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-1 border-t border-(--color-border)/60"></div>
|
||||
<form method="POST" action="/logout">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
class="w-full justify-start px-3 py-2.5 h-auto text-sm font-medium text-red-400 hover:bg-zinc-800 hover:text-red-300"
|
||||
class="w-full justify-start px-3 py-2.5 h-auto text-sm font-medium text-(--color-danger) hover:bg-(--color-surface-2) hover:text-(--color-danger)"
|
||||
>
|
||||
Sign out
|
||||
{m.nav_sign_out()}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -395,19 +565,19 @@
|
||||
{/key}
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-zinc-800 mt-auto">
|
||||
<div class="max-w-6xl mx-auto px-4 py-6 flex flex-col items-center gap-4 text-xs text-zinc-600">
|
||||
<footer class="border-t border-(--color-border) mt-auto">
|
||||
<div class="max-w-6xl mx-auto px-4 py-6 flex flex-col items-center gap-4 text-xs text-(--color-muted)">
|
||||
<!-- Top row: site links -->
|
||||
<nav class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
|
||||
<a href="/books" class="hover:text-zinc-400 transition-colors">Library</a>
|
||||
<a href="/catalogue" class="hover:text-zinc-400 transition-colors">Catalogue</a>
|
||||
<a href="/books" class="hover:text-(--color-text) transition-colors">{m.footer_library()}</a>
|
||||
<a href="/catalogue" class="hover:text-(--color-text) transition-colors">{m.footer_catalogue()}</a>
|
||||
<a
|
||||
href="https://feedback.libnovel.cc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-zinc-400 transition-colors flex items-center gap-1"
|
||||
class="hover:text-(--color-text) transition-colors flex items-center gap-1"
|
||||
>
|
||||
Feedback
|
||||
{m.footer_feedback()}
|
||||
<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" />
|
||||
@@ -417,7 +587,7 @@
|
||||
href="https://novelfire.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-zinc-400 transition-colors flex items-center gap-1"
|
||||
class="hover:text-(--color-text) transition-colors flex items-center gap-1"
|
||||
>
|
||||
novelfire.net
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -425,25 +595,34 @@
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</nav>
|
||||
</nav>
|
||||
<!-- Bottom row: legal links + copyright -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-zinc-700">
|
||||
<a href="/disclaimer" class="hover:text-zinc-500 transition-colors">Disclaimer</a>
|
||||
<a href="/privacy" class="hover:text-zinc-500 transition-colors">Privacy</a>
|
||||
<a href="/dmca" class="hover:text-zinc-500 transition-colors">DMCA</a>
|
||||
<span>© {new Date().getFullYear()} libnovel</span>
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-(--color-muted)">
|
||||
<a href="/disclaimer" class="hover:text-(--color-text) transition-colors">{m.footer_disclaimer()}</a>
|
||||
<a href="/privacy" class="hover:text-(--color-text) transition-colors">{m.footer_privacy()}</a>
|
||||
<a href="/dmca" class="hover:text-(--color-text) transition-colors">{m.footer_dmca()}</a>
|
||||
<span>{m.footer_copyright({ year: String(new Date().getFullYear()) })}</span>
|
||||
</div>
|
||||
<!-- Build version / commit SHA -->
|
||||
<div class="text-zinc-700 tabular-nums font-mono">
|
||||
<!-- Build version / commit SHA / build time -->
|
||||
{#snippet buildTime()}
|
||||
{#if env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown'}
|
||||
{@const d = new Date(env.PUBLIC_BUILD_TIME)}
|
||||
<span class="text-(--color-muted)" title="Build time">
|
||||
· {d.toUTCString().replace(' GMT', ' UTC').replace(/:\d\d /, ' ')}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<div class="text-xs tabular-nums font-mono px-2 py-0.5 rounded bg-(--color-surface-2) border border-(--color-border)">
|
||||
{#if env.PUBLIC_BUILD_VERSION && env.PUBLIC_BUILD_VERSION !== 'dev'}
|
||||
<span title="Build version">{env.PUBLIC_BUILD_VERSION}</span>
|
||||
<span class="text-(--color-text)" title="Build version">{env.PUBLIC_BUILD_VERSION}</span>
|
||||
{#if env.PUBLIC_BUILD_COMMIT && env.PUBLIC_BUILD_COMMIT !== 'unknown'}
|
||||
<span class="text-zinc-800 select-all" title="Commit SHA"
|
||||
<span class="text-(--color-muted) select-all" title="Commit SHA"
|
||||
>+{env.PUBLIC_BUILD_COMMIT.slice(0, 7)}</span
|
||||
>
|
||||
{/if}
|
||||
{@render buildTime()}
|
||||
{:else}
|
||||
<span class="text-zinc-800">dev</span>
|
||||
<span class="text-(--color-muted)">dev</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -452,20 +631,20 @@
|
||||
|
||||
<!-- ── Persistent mini-player bar ─────────────────────────────────────────── -->
|
||||
{#if audioStore.active}
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-zinc-900 border-t border-zinc-700 shadow-2xl">
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface) border-t border-(--color-border) shadow-2xl">
|
||||
|
||||
<!-- Chapter list drawer (slides up above the mini-bar) -->
|
||||
{#if chapterDrawerOpen && audioStore.chapters.length > 0}
|
||||
<div class="border-b border-zinc-700 bg-zinc-900 max-h-[32rem] overflow-y-auto">
|
||||
<div class="border-b border-(--color-border) bg-(--color-surface) max-h-[32rem] overflow-y-auto">
|
||||
<div class="max-w-6xl mx-auto px-4">
|
||||
<div class="flex items-center justify-between py-2 border-b border-zinc-800 sticky top-0 bg-zinc-900">
|
||||
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Chapters</span>
|
||||
<div class="flex items-center justify-between py-2 border-b border-(--color-border) sticky top-0 bg-(--color-surface)">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.player_chapters()}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
aria-label="Close chapter list"
|
||||
class="h-6 w-6 text-zinc-600 hover:text-zinc-300"
|
||||
aria-label={m.player_close_chapter_list()}
|
||||
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
|
||||
>
|
||||
<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="M19 9l-7 7-7-7"/>
|
||||
@@ -476,16 +655,16 @@
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-zinc-100 {ch.number === audioStore.chapter
|
||||
? 'text-amber-400 font-semibold'
|
||||
: 'text-zinc-400'}"
|
||||
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
|
||||
? 'text-(--color-brand) font-semibold'
|
||||
: 'text-(--color-muted)'}"
|
||||
>
|
||||
<span class="tabular-nums text-zinc-600 w-8 shrink-0 text-right">
|
||||
<span class="tabular-nums text-(--color-muted) opacity-60 w-8 shrink-0 text-right">
|
||||
{ch.number}
|
||||
</span>
|
||||
<span class="truncate">{ch.title || `Chapter ${ch.number}`}</span>
|
||||
<span class="truncate">{ch.title || m.player_chapter_n({ n: String(ch.number) })}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-3 h-3 shrink-0 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
@@ -497,9 +676,9 @@
|
||||
|
||||
<!-- Generation progress bar (sits at very top of the bar) -->
|
||||
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
|
||||
<div class="h-0.5 bg-zinc-800">
|
||||
<div class="h-0.5 bg-(--color-surface-2)">
|
||||
<div
|
||||
class="h-full bg-amber-400 transition-none"
|
||||
class="h-full bg-(--color-brand) transition-none"
|
||||
style="width: {audioStore.progress}%"
|
||||
></div>
|
||||
</div>
|
||||
@@ -508,12 +687,13 @@
|
||||
<div class="px-0">
|
||||
<input
|
||||
type="range"
|
||||
aria-label={m.player_seek_label()}
|
||||
min="0"
|
||||
max={audioStore.duration || 0}
|
||||
value={audioStore.currentTime}
|
||||
oninput={seek}
|
||||
class="w-full h-1 accent-amber-400 cursor-pointer block"
|
||||
style="margin: 0; border-radius: 0;"
|
||||
class="w-full h-1 accent-[--color-brand] cursor-pointer block"
|
||||
style="margin: 0; border-radius: 0; accent-color: var(--color-brand);"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -522,27 +702,27 @@
|
||||
|
||||
<!-- Track info (click to open chapter list drawer) -->
|
||||
<button
|
||||
class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-zinc-800 transition-colors"
|
||||
class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-(--color-surface-2) transition-colors"
|
||||
onclick={() => { if (audioStore.chapters.length > 0) chapterDrawerOpen = !chapterDrawerOpen; }}
|
||||
aria-label={audioStore.chapters.length > 0 ? 'Toggle chapter list' : undefined}
|
||||
title={audioStore.chapters.length > 0 ? 'Chapter list' : undefined}
|
||||
aria-label={audioStore.chapters.length > 0 ? m.player_toggle_chapter_list() : undefined}
|
||||
title={audioStore.chapters.length > 0 ? m.player_chapter_list_label() : undefined}
|
||||
>
|
||||
{#if audioStore.chapterTitle}
|
||||
<p class="text-xs text-zinc-100 truncate leading-tight">{audioStore.chapterTitle}</p>
|
||||
<p class="text-xs text-(--color-text) truncate leading-tight">{audioStore.chapterTitle}</p>
|
||||
{/if}
|
||||
{#if audioStore.bookTitle}
|
||||
<p class="text-xs text-zinc-500 truncate leading-tight">{audioStore.bookTitle}</p>
|
||||
<p class="text-xs text-(--color-muted) truncate leading-tight">{audioStore.bookTitle}</p>
|
||||
{/if}
|
||||
{#if audioStore.status === 'generating'}
|
||||
<p class="text-xs text-amber-400 leading-tight">
|
||||
Generating… {Math.round(audioStore.progress)}%
|
||||
<p class="text-xs text-(--color-brand) leading-tight">
|
||||
{m.player_generating({ percent: String(Math.round(audioStore.progress)) })}
|
||||
</p>
|
||||
{:else if audioStore.status === 'ready'}
|
||||
<p class="text-xs text-zinc-500 tabular-nums leading-tight">
|
||||
<p class="text-xs text-(--color-muted) tabular-nums leading-tight">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
</p>
|
||||
{:else if audioStore.status === 'loading'}
|
||||
<p class="text-xs text-zinc-500 leading-tight">Loading…</p>
|
||||
<p class="text-xs text-(--color-muted) leading-tight">{m.player_loading()}</p>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -552,8 +732,8 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={skipBack}
|
||||
title="Back 15s"
|
||||
aria-label="Rewind 15 seconds"
|
||||
title={m.player_back_15()}
|
||||
aria-label={m.player_rewind_15()}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||
@@ -561,11 +741,11 @@
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
<!-- Play / Pause — custom circular amber style, kept as raw button -->
|
||||
<!-- Play / Pause — custom circular brand style, kept as raw button -->
|
||||
<button
|
||||
onclick={togglePlay}
|
||||
class="w-10 h-10 rounded-full bg-amber-400 text-zinc-900 flex items-center justify-center hover:bg-amber-300 transition-colors flex-shrink-0"
|
||||
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
|
||||
class="w-10 h-10 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors flex-shrink-0"
|
||||
aria-label={audioStore.isPlaying ? m.player_pause() : m.player_play()}
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
@@ -583,8 +763,8 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={skipForward}
|
||||
title="Forward 30s"
|
||||
aria-label="Skip 30 seconds"
|
||||
title={m.player_forward_30()}
|
||||
aria-label={m.player_skip_30()}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
|
||||
@@ -595,9 +775,9 @@
|
||||
<!-- Speed control — fixed-width pill, kept as raw button -->
|
||||
<button
|
||||
onclick={cycleSpeed}
|
||||
class="text-xs font-semibold text-zinc-300 hover:text-amber-400 transition-colors px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 flex-shrink-0 tabular-nums w-12 text-center"
|
||||
title="Change playback speed"
|
||||
aria-label="Playback speed {audioStore.speed}x"
|
||||
class="text-xs font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors px-2 py-1 rounded bg-(--color-surface-2) hover:bg-(--color-surface-3) flex-shrink-0 tabular-nums w-12 text-center"
|
||||
title={m.player_change_speed()}
|
||||
aria-label={m.player_speed_label({ speed: String(audioStore.speed) })}
|
||||
>
|
||||
{audioStore.speed}×
|
||||
</button>
|
||||
@@ -608,17 +788,17 @@
|
||||
class={cn(
|
||||
'relative p-1.5 rounded flex-shrink-0 transition-colors',
|
||||
audioStore.autoNext
|
||||
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
||||
: 'text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800'
|
||||
? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25'
|
||||
: 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
title={audioStore.autoNext
|
||||
? audioStore.nextStatus === 'prefetched'
|
||||
? `Auto-next on — Ch.${audioStore.nextChapter} ready`
|
||||
? m.player_auto_next_ready({ n: String(audioStore.nextChapter) })
|
||||
: audioStore.nextStatus === 'prefetching'
|
||||
? `Auto-next on — preparing Ch.${audioStore.nextChapter}…`
|
||||
: 'Auto-next on'
|
||||
: 'Auto-next off'}
|
||||
aria-label="Auto-next {audioStore.autoNext ? 'on' : 'off'}"
|
||||
? m.player_auto_next_preparing({ n: String(audioStore.nextChapter) })
|
||||
: m.player_auto_next_on()
|
||||
: m.player_auto_next_off()}
|
||||
aria-label={m.player_auto_next_aria({ state: audioStore.autoNext ? m.common_on() : m.common_off() })}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<!-- "skip to end" / auto-advance icon -->
|
||||
@@ -627,14 +807,14 @@
|
||||
</svg>
|
||||
<!-- Prefetch status dot -->
|
||||
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
|
||||
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></span>
|
||||
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
|
||||
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
|
||||
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else if audioStore.status === 'generating'}
|
||||
<!-- Spinner during generation -->
|
||||
<svg class="w-6 h-6 text-amber-400 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<svg class="w-6 h-6 text-(--color-brand) animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
@@ -645,8 +825,8 @@
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{audioStore.chapter}"
|
||||
class="shrink-0 rounded overflow-hidden hover:opacity-80 transition-opacity"
|
||||
title="Go to chapter"
|
||||
aria-label="Go to chapter"
|
||||
title={m.player_go_to_chapter()}
|
||||
aria-label={m.player_go_to_chapter()}
|
||||
>
|
||||
{#if audioStore.cover}
|
||||
<img
|
||||
@@ -656,8 +836,8 @@
|
||||
/>
|
||||
{:else}
|
||||
<!-- Fallback book icon -->
|
||||
<div class="w-8 h-11 flex items-center justify-center bg-zinc-800 rounded border border-zinc-700">
|
||||
<svg class="w-4 h-4 text-zinc-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<div class="w-8 h-11 flex items-center justify-center bg-(--color-surface-2) rounded border border-(--color-border)">
|
||||
<svg class="w-4 h-4 text-(--color-muted)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -670,9 +850,9 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={dismiss}
|
||||
title="Close player"
|
||||
aria-label="Close player"
|
||||
class="text-zinc-600 hover:text-zinc-400 flex-shrink-0"
|
||||
title={m.player_close()}
|
||||
aria-label={m.player_close()}
|
||||
class="text-(--color-muted) hover:text-(--color-text) flex-shrink-0"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -16,22 +17,22 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>libnovel</title>
|
||||
<title>{m.home_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="flex gap-6 mb-8 text-center">
|
||||
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6">
|
||||
<p class="text-2xl font-bold text-amber-400">{data.stats.totalBooks}</p>
|
||||
<p class="text-xs text-zinc-400 mt-0.5">Books</p>
|
||||
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
|
||||
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.totalBooks}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_books()}</p>
|
||||
</div>
|
||||
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6">
|
||||
<p class="text-2xl font-bold text-amber-400">{data.stats.totalChapters.toLocaleString()}</p>
|
||||
<p class="text-xs text-zinc-400 mt-0.5">Chapters</p>
|
||||
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
|
||||
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.totalChapters.toLocaleString()}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_chapters()}</p>
|
||||
</div>
|
||||
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6">
|
||||
<p class="text-2xl font-bold text-amber-400">{data.stats.booksInProgress}</p>
|
||||
<p class="text-xs text-zinc-400 mt-0.5">In progress</p>
|
||||
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
|
||||
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.booksInProgress}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_in_progress()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,16 +40,16 @@
|
||||
{#if data.continueReading.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-lg font-bold text-zinc-100">Continue Reading</h2>
|
||||
<a href="/books" class="text-xs text-amber-400 hover:text-amber-300">View all</a>
|
||||
<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="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.continueReading as { book, chapter }}
|
||||
<a
|
||||
href="/books/{book.slug}/chapters/{chapter}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
|
||||
>
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
|
||||
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden relative">
|
||||
{#if book.cover}
|
||||
<img
|
||||
src={book.cover}
|
||||
@@ -57,7 +58,7 @@
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
@@ -65,14 +66,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Chapter badge overlay -->
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-amber-400 text-zinc-900 font-bold px-1.5 py-0.5 rounded">
|
||||
ch.{chapter}
|
||||
<span 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>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
<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-zinc-500 truncate mt-0.5">{book.author}</p>
|
||||
<p class="text-xs text-(--color-muted) truncate mt-0.5">{book.author}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
@@ -85,17 +86,17 @@
|
||||
{#if data.recentlyUpdated.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-lg font-bold text-zinc-100">Recently Updated</h2>
|
||||
<a href="/books" class="text-xs text-amber-400 hover:text-amber-300">View all</a>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
|
||||
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.recentlyUpdated as book}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
|
||||
>
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden">
|
||||
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
|
||||
{#if book.cover}
|
||||
<img
|
||||
src={book.cover}
|
||||
@@ -104,7 +105,7 @@
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
@@ -113,17 +114,17 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-2 flex flex-col gap-1">
|
||||
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
<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-zinc-400 truncate">{book.author}</p>
|
||||
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
|
||||
{/if}
|
||||
{#if book.status}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300 self-start">{book.status}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-text) self-start">{book.status}</span>
|
||||
{/if}
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-auto pt-1">
|
||||
{#each genres.slice(0, 2) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -136,14 +137,14 @@
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if data.continueReading.length === 0 && data.recentlyUpdated.length === 0}
|
||||
<div class="text-center py-20 text-zinc-500">
|
||||
<p class="text-lg font-semibold text-zinc-300 mb-2">Your library is empty</p>
|
||||
<p class="text-sm mb-6">Discover novels and scrape them into your library.</p>
|
||||
<div class="text-center py-20 text-(--color-muted)">
|
||||
<p class="text-lg font-semibold text-(--color-text) mb-2">{m.home_empty_title()}</p>
|
||||
<p class="text-sm mb-6">{m.home_empty_body()}</p>
|
||||
<a
|
||||
href="/catalogue"
|
||||
class="inline-block px-6 py-3 bg-amber-400 text-zinc-900 font-semibold rounded hover:bg-amber-300 transition-colors"
|
||||
class="inline-block px-6 py-3 bg-(--color-brand) text-(--color-surface) font-semibold rounded hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Discover Novels
|
||||
{m.home_discover_novels()}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -152,16 +153,16 @@
|
||||
{#if data.subscriptionFeed.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-baseline justify-between mb-3">
|
||||
<h2 class="text-lg font-bold text-zinc-100">From People You Follow</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_from_following()}</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{#each data.subscriptionFeed as { book, readerUsername }}
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
|
||||
>
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden">
|
||||
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
|
||||
{#if book.cover}
|
||||
<img
|
||||
src={book.cover}
|
||||
@@ -170,7 +171,7 @@
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
|
||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
@@ -179,18 +180,18 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-2 flex flex-col gap-1">
|
||||
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
<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-zinc-400 truncate">{book.author}</p>
|
||||
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
|
||||
{/if}
|
||||
<!-- Reader attribution -->
|
||||
<p class="text-xs text-zinc-600 truncate mt-0.5">
|
||||
via <span class="text-amber-500/70">{readerUsername}</span>
|
||||
<p class="text-xs text-(--color-muted) truncate mt-0.5">
|
||||
{m.home_via_reader({ username: readerUsername })}
|
||||
</p>
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-auto pt-1">
|
||||
{#each genres.slice(0, 1) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
const adminTabs = [
|
||||
const internalLinks = [
|
||||
{ href: '/admin/scrape', label: 'Scrape' },
|
||||
{ href: '/admin/audio', label: 'Audio' }
|
||||
{ href: '/admin/audio', label: 'Audio' },
|
||||
{ href: '/admin/translation', label: 'Translation' },
|
||||
{ href: '/admin/changelog', label: 'Changelog' }
|
||||
];
|
||||
|
||||
const toolTabs = [
|
||||
const externalLinks = [
|
||||
{ href: 'https://feedback.libnovel.cc', label: 'Feedback' },
|
||||
{ href: 'https://errors.libnovel.cc', label: 'Errors' },
|
||||
{ href: 'https://analytics.libnovel.cc', label: 'Analytics' },
|
||||
@@ -21,36 +24,51 @@
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- Admin nav: internal pages + external tools -->
|
||||
<div class="mb-6 flex flex-wrap items-center gap-3">
|
||||
<!-- Internal admin pages -->
|
||||
<div class="flex gap-1 bg-zinc-800 rounded-lg p-1 border border-zinc-700">
|
||||
{#each adminTabs as tab}
|
||||
<a
|
||||
href={tab.href}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{page.url.pathname.startsWith(tab.href)
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-400 hover:text-zinc-200'}"
|
||||
>
|
||||
{tab.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex min-h-[calc(100vh-4rem)] gap-0">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-48 shrink-0 border-r border-(--color-border) px-3 py-6 flex flex-col gap-6">
|
||||
<!-- Internal pages -->
|
||||
<div>
|
||||
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_pages_label()}</p>
|
||||
<nav class="flex flex-col gap-0.5">
|
||||
{#each internalLinks as link}
|
||||
<a
|
||||
href={link.href}
|
||||
class="px-2 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{page.url.pathname.startsWith(link.href)
|
||||
? 'bg-(--color-surface-2) text-(--color-text)'
|
||||
: 'text-(--color-muted) hover:bg-(--color-surface-2)/60 hover:text-(--color-text)'}"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- External tools (open in new tab) -->
|
||||
<div class="flex gap-1 bg-zinc-800 rounded-lg p-1 border border-zinc-700">
|
||||
{#each toolTabs as tool}
|
||||
<a
|
||||
href={tool.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
{tool.label} ↗
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- External tools -->
|
||||
<div>
|
||||
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_tools_label()}</p>
|
||||
<nav class="flex flex-col gap-0.5">
|
||||
{#each externalLinks as link}
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-2 py-1.5 rounded-md text-sm font-medium text-(--color-muted) hover:bg-(--color-surface-2)/60 hover:text-(--color-text) transition-colors flex items-center justify-between"
|
||||
>
|
||||
{link.label}
|
||||
<svg class="w-3 h-3 shrink-0 opacity-50" 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>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 min-w-0 px-8 py-6">
|
||||
{@render children?.()}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{@render children?.()}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import type { AudioJob, AudioCacheEntry } from '$lib/server/pocketbase';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -33,10 +34,10 @@
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function jobStatusColor(status: string) {
|
||||
if (status === 'done') return 'text-green-400';
|
||||
if (status === 'generating') return 'text-amber-400 animate-pulse';
|
||||
if (status === 'generating') return 'text-(--color-brand) animate-pulse';
|
||||
if (status === 'pending') return 'text-sky-400 animate-pulse';
|
||||
if (status === 'failed') return 'text-red-400';
|
||||
return 'text-zinc-300';
|
||||
if (status === 'failed') return 'text-(--color-danger)';
|
||||
return 'text-(--color-text)';
|
||||
}
|
||||
|
||||
function fmtDate(s: string) {
|
||||
@@ -94,36 +95,36 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Audio — libnovel admin</title>
|
||||
<title>{m.admin_audio_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Audio</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">{m.admin_audio_heading()}</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
{stats.total} job{stats.total !== 1 ? 's' : ''} ·
|
||||
<span class="text-green-400">{stats.done} done</span>
|
||||
{#if stats.failed > 0}
|
||||
· <span class="text-red-400">{stats.failed} failed</span>
|
||||
· <span class="text-(--color-danger)">{stats.failed} failed</span>
|
||||
{/if}
|
||||
{#if stats.inFlight > 0}
|
||||
· <span class="text-amber-400 animate-pulse">{stats.inFlight} in-flight</span>
|
||||
· <span class="text-(--color-brand) animate-pulse">{stats.inFlight} in-flight</span>
|
||||
{/if}
|
||||
· {entries.length} cached file{entries.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 bg-zinc-800 rounded-lg p-1 w-fit border border-zinc-700">
|
||||
<div class="flex gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
|
||||
<button
|
||||
onclick={() => (activeTab = 'jobs')}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{activeTab === 'jobs' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'}"
|
||||
{activeTab === 'jobs' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Jobs
|
||||
{#if stats.inFlight > 0}
|
||||
<span class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-amber-400 text-zinc-900 text-[10px] font-bold">
|
||||
<span class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[10px] font-bold">
|
||||
{stats.inFlight}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -131,7 +132,7 @@
|
||||
<button
|
||||
onclick={() => (activeTab = 'cache')}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{activeTab === 'cache' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'}"
|
||||
{activeTab === 'cache' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Cache
|
||||
</button>
|
||||
@@ -142,19 +143,19 @@
|
||||
<input
|
||||
type="search"
|
||||
bind:value={jobsQ}
|
||||
placeholder="Filter by slug, voice or status…"
|
||||
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
placeholder={m.admin_audio_filter_jobs()}
|
||||
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
|
||||
{#if filteredJobs.length === 0}
|
||||
<p class="text-zinc-500 text-sm py-8 text-center">
|
||||
{jobsQ.trim() ? 'No matching jobs.' : 'No audio jobs yet.'}
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">
|
||||
{jobsQ.trim() ? m.admin_audio_no_matching_jobs() : m.admin_audio_no_jobs()}
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700">
|
||||
<div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
|
||||
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Book</th>
|
||||
<th class="px-4 py-3 text-right">Ch.</th>
|
||||
@@ -164,23 +165,23 @@
|
||||
<th class="px-4 py-3 text-left">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-700/50">
|
||||
<tbody class="divide-y divide-(--color-border)/50">
|
||||
{#each filteredJobs as job}
|
||||
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
|
||||
<td class="px-4 py-3 text-zinc-200 font-medium">
|
||||
<a href="/books/{job.slug}" class="hover:text-amber-400 transition-colors">{job.slug}</a>
|
||||
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
|
||||
<td class="px-4 py-3 text-(--color-text) font-medium">
|
||||
<a href="/books/{job.slug}" class="hover:text-(--color-brand) transition-colors">{job.slug}</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-zinc-400">{job.chapter}</td>
|
||||
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{job.voice}</td>
|
||||
<td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs">{job.voice}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(job.started)}</td>
|
||||
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{duration(job.started, job.finished)}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{duration(job.started, job.finished)}</td>
|
||||
</tr>
|
||||
{#if job.error_message}
|
||||
<tr class="bg-red-950/20">
|
||||
<td colspan="6" class="px-4 py-2 text-xs text-red-400 font-mono">{job.error_message}</td>
|
||||
<tr class="bg-(--color-danger)/10">
|
||||
<td colspan="6" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{job.error_message}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -191,21 +192,21 @@
|
||||
<!-- Mobile cards -->
|
||||
<div class="sm:hidden space-y-3">
|
||||
{#each filteredJobs as job}
|
||||
<div class="bg-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2">
|
||||
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<a href="/books/{job.slug}" class="text-zinc-200 font-medium hover:text-amber-400 transition-colors truncate">
|
||||
<a href="/books/{job.slug}" class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors truncate">
|
||||
{job.slug}
|
||||
</a>
|
||||
<span class="shrink-0 text-xs font-semibold {jobStatusColor(job.status)}">{job.status}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1 text-xs">
|
||||
<span class="text-zinc-500">Chapter</span><span class="text-zinc-400 text-right">{job.chapter}</span>
|
||||
<span class="text-zinc-500">Voice</span><span class="text-zinc-400 font-mono text-right truncate">{job.voice}</span>
|
||||
<span class="text-zinc-500">Started</span><span class="text-zinc-400 text-right">{fmtDate(job.started)}</span>
|
||||
<span class="text-zinc-500">Duration</span><span class="text-zinc-400 text-right">{duration(job.started, job.finished)}</span>
|
||||
<span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{job.chapter}</span>
|
||||
<span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{job.voice}</span>
|
||||
<span class="text-(--color-muted)">Started</span><span class="text-(--color-muted) text-right">{fmtDate(job.started)}</span>
|
||||
<span class="text-(--color-muted)">Duration</span><span class="text-(--color-muted) text-right">{duration(job.started, job.finished)}</span>
|
||||
</div>
|
||||
{#if job.error_message}
|
||||
<p class="text-xs text-red-400 font-mono break-all">{job.error_message}</p>
|
||||
<p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -218,19 +219,19 @@
|
||||
<input
|
||||
type="search"
|
||||
bind:value={cacheQ}
|
||||
placeholder="Filter by slug, chapter or voice…"
|
||||
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
placeholder={m.admin_audio_filter_cache()}
|
||||
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
|
||||
{#if filteredCache.length === 0}
|
||||
<p class="text-zinc-500 text-sm py-8 text-center">
|
||||
{cacheQ.trim() ? 'No results.' : 'Audio cache is empty.'}
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">
|
||||
{cacheQ.trim() ? m.admin_audio_no_cache_results() : m.admin_audio_cache_empty()}
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700">
|
||||
<div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
|
||||
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Book</th>
|
||||
<th class="px-4 py-3 text-left">Chapter</th>
|
||||
@@ -239,19 +240,19 @@
|
||||
<th class="px-4 py-3 text-left">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-700/50">
|
||||
<tbody class="divide-y divide-(--color-border)/50">
|
||||
{#each filteredCache as entry}
|
||||
{@const parts = parseCacheKey(entry.cache_key)}
|
||||
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
|
||||
<td class="px-4 py-3 text-zinc-200 font-medium">
|
||||
<a href="/books/{parts.slug}" class="hover:text-amber-400 transition-colors">{parts.slug}</a>
|
||||
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
|
||||
<td class="px-4 py-3 text-(--color-text) font-medium">
|
||||
<a href="/books/{parts.slug}" class="hover:text-(--color-brand) transition-colors">{parts.slug}</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-400">{parts.chapter}</td>
|
||||
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{parts.voice}</td>
|
||||
<td class="px-4 py-3 text-zinc-500 font-mono text-xs truncate max-w-[14rem]" title={entry.filename}>
|
||||
<td class="px-4 py-3 text-(--color-muted)">{parts.chapter}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs">{parts.voice}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs truncate max-w-[14rem]" title={entry.filename}>
|
||||
{entry.filename}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(entry.updated)}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(entry.updated)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
@@ -262,17 +263,17 @@
|
||||
<div class="sm:hidden space-y-3">
|
||||
{#each filteredCache as entry}
|
||||
{@const parts = parseCacheKey(entry.cache_key)}
|
||||
<div class="bg-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2">
|
||||
<a href="/books/{parts.slug}" class="text-zinc-200 font-medium hover:text-amber-400 transition-colors block truncate">
|
||||
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
|
||||
<a href="/books/{parts.slug}" class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors block truncate">
|
||||
{parts.slug}
|
||||
</a>
|
||||
<div class="grid grid-cols-2 gap-1 text-xs">
|
||||
<span class="text-zinc-500">Chapter</span><span class="text-zinc-400 text-right">{parts.chapter}</span>
|
||||
<span class="text-zinc-500">Voice</span><span class="text-zinc-400 font-mono text-right truncate">{parts.voice}</span>
|
||||
<span class="text-zinc-500">Updated</span><span class="text-zinc-400 text-right">{fmtDate(entry.updated)}</span>
|
||||
<span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{parts.chapter}</span>
|
||||
<span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{parts.voice}</span>
|
||||
<span class="text-(--color-muted)">Updated</span><span class="text-(--color-muted) text-right">{fmtDate(entry.updated)}</span>
|
||||
</div>
|
||||
{#if entry.filename}
|
||||
<p class="text-xs text-zinc-500 font-mono truncate" title={entry.filename}>{entry.filename}</p>
|
||||
<p class="text-xs text-(--color-muted) font-mono truncate" title={entry.filename}>{entry.filename}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
26
ui/src/routes/admin/changelog/+page.server.ts
Normal file
26
ui/src/routes/admin/changelog/+page.server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export interface Release {
|
||||
id: number;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string;
|
||||
published_at: string;
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
try {
|
||||
// releases.json is baked into the image at build time by CI.
|
||||
// SvelteKit Node adapter copies static/ → build/client/, so the file
|
||||
// lives at <cwd>/build/client/releases.json in production.
|
||||
const raw = readFileSync(join(process.cwd(), 'build', 'client', 'releases.json'), 'utf-8');
|
||||
const releases: Release[] = JSON.parse(raw);
|
||||
return { releases: releases.filter((r) => !r.draft) };
|
||||
} catch (e) {
|
||||
return { releases: [], error: String(e) };
|
||||
}
|
||||
};
|
||||
60
ui/src/routes/admin/changelog/+page.svelte
Normal file
60
ui/src/routes/admin/changelog/+page.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
function fmtDate(s: string) {
|
||||
return new Date(s).toLocaleDateString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.admin_changelog_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6 max-w-2xl">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-xl font-semibold text-(--color-text) flex-1">{m.admin_changelog_heading()}</h1>
|
||||
<a
|
||||
href="https://gitea.kalekber.cc/kamil/libnovel/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors flex items-center gap-1"
|
||||
>
|
||||
{m.admin_changelog_gitea()}
|
||||
<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>
|
||||
|
||||
{#if data.error}
|
||||
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: data.error })}</p>
|
||||
{:else if data.releases.length === 0}
|
||||
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
|
||||
{:else}
|
||||
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
|
||||
{#each data.releases as release}
|
||||
<div class="px-5 py-4 bg-(--color-surface) space-y-2">
|
||||
<div class="flex items-baseline gap-3 flex-wrap">
|
||||
<span class="font-mono text-sm font-semibold text-(--color-brand)">{release.tag_name}</span>
|
||||
{#if release.name && release.name !== release.tag_name}
|
||||
<span class="text-sm text-(--color-text)">{release.name}</span>
|
||||
{/if}
|
||||
{#if release.prerelease}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">pre-release</span>
|
||||
{/if}
|
||||
<span class="text-xs text-(--color-muted) ml-auto">{fmtDate(release.published_at)}</span>
|
||||
</div>
|
||||
{#if release.body.trim()}
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import type { ScrapingTask } from '$lib/server/pocketbase';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -193,10 +194,10 @@
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
function statusColor(status: string) {
|
||||
if (status === 'done') return 'text-green-400';
|
||||
if (status === 'running') return 'text-amber-400 animate-pulse';
|
||||
if (status === 'failed') return 'text-red-400';
|
||||
if (status === 'cancelled') return 'text-zinc-400';
|
||||
return 'text-zinc-300';
|
||||
if (status === 'running') return 'text-(--color-brand) animate-pulse';
|
||||
if (status === 'failed') return 'text-(--color-danger)';
|
||||
if (status === 'cancelled') return 'text-(--color-muted)';
|
||||
return 'text-(--color-text)';
|
||||
}
|
||||
|
||||
function fmtDate(s: string) {
|
||||
@@ -228,151 +229,130 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Scrape tasks — libnovel admin</title>
|
||||
<title>{m.admin_scrape_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Scrape tasks</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">
|
||||
Job status:
|
||||
{#if running}
|
||||
<span class="text-amber-400 font-medium animate-pulse">Running</span>
|
||||
{:else}
|
||||
<span class="text-green-400 font-medium">Idle</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h1 class="text-xl font-semibold text-(--color-text) flex-1">{m.admin_scrape_heading()}</h1>
|
||||
<span class="text-xs {running ? 'text-(--color-brand) animate-pulse' : 'text-green-500'}">
|
||||
● {running ? m.admin_scrape_status_running() : m.admin_scrape_status_idle()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Scrape controls -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Compact controls -->
|
||||
<div class="divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
|
||||
<!-- Full catalogue -->
|
||||
<div class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-zinc-300">Scrape full catalogue</h2>
|
||||
<p class="text-xs text-zinc-500 mt-1">Re-crawls all novelfire.net pages and picks up new books.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 px-4 py-3 bg-(--color-surface)">
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_full_catalogue()}</span>
|
||||
<button
|
||||
onclick={triggerCatalogueScrape}
|
||||
disabled={running || cataloguing}
|
||||
class="w-full px-4 py-2 rounded-lg bg-amber-600 text-zinc-900 font-semibold text-sm hover:bg-amber-500 transition-colors disabled:opacity-50"
|
||||
class="px-3 py-1.5 rounded-md bg-(--color-brand) text-(--color-surface) font-semibold text-xs hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cataloguing ? 'Queuing…' : running ? 'Already running…' : 'Start catalogue scrape'}
|
||||
{cataloguing ? m.admin_scrape_queuing() : running ? m.admin_scrape_running() : m.admin_scrape_start()}
|
||||
</button>
|
||||
{#if catalogueError}
|
||||
<p class="text-sm text-red-400">{catalogueError}</p>
|
||||
{/if}
|
||||
{#if catalogueError}<span class="text-xs text-(--color-danger)">{catalogueError}</span>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Single book -->
|
||||
<div id="book-form" class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
|
||||
<h2 class="text-sm font-semibold text-zinc-300">Scrape a single book</h2>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={scrapeUrl}
|
||||
placeholder="https://novelfire.net/book/…"
|
||||
class="flex-1 min-w-0 bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<button
|
||||
onclick={() => triggerBookScrape(scrapeUrl)}
|
||||
disabled={!scrapeUrl.trim() || running || scraping}
|
||||
class="shrink-0 px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{scraping ? 'Queuing…' : 'Scrape'}
|
||||
</button>
|
||||
</div>
|
||||
{#if scrapeError}
|
||||
<p class="text-sm text-red-400">{scrapeError}</p>
|
||||
{/if}
|
||||
<div id="book-form" class="flex items-center gap-3 px-4 py-3 bg-(--color-surface)">
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_single_book()}</span>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={scrapeUrl}
|
||||
placeholder="https://novelfire.net/book/…"
|
||||
class="flex-1 min-w-0 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<button
|
||||
onclick={() => triggerBookScrape(scrapeUrl)}
|
||||
disabled={!scrapeUrl.trim() || running || scraping}
|
||||
class="shrink-0 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{scraping ? m.admin_scrape_queuing() : m.admin_scrape_submit()}
|
||||
</button>
|
||||
{#if scrapeError}<span class="text-xs text-(--color-danger)">{scrapeError}</span>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Range scrape -->
|
||||
<div id="range-form" class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
|
||||
<h2 class="text-sm font-semibold text-zinc-300">Scrape chapter range</h2>
|
||||
<div id="range-form" class="flex items-center gap-3 px-4 py-3 bg-(--color-surface) flex-wrap">
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_range()}</span>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={rangeUrl}
|
||||
placeholder="https://novelfire.net/book/…"
|
||||
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
class="flex-1 min-w-0 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={rangeFrom}
|
||||
min="1"
|
||||
placeholder="From ch."
|
||||
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={rangeTo}
|
||||
min="1"
|
||||
placeholder="To ch. (opt)"
|
||||
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<button
|
||||
onclick={triggerRangeScrape}
|
||||
disabled={!rangeUrl.trim() || rangeFrom === null || running || ranging}
|
||||
class="shrink-0 px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{ranging ? 'Queuing…' : 'Go'}
|
||||
</button>
|
||||
</div>
|
||||
{#if rangeError}
|
||||
<p class="text-sm text-red-400">{rangeError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick-scrape genre links -->
|
||||
<div class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
|
||||
<h2 class="text-sm font-semibold text-zinc-300">Quick genre refresh</h2>
|
||||
<p class="text-xs text-zinc-500">Paste one of these into the single-book scraper to re-index a genre, or use them as starting points for range scrapes.</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each quickScrapes as qs}
|
||||
<button
|
||||
onclick={() => { scrapeUrl = qs.url; }}
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-300 border border-zinc-600 hover:border-amber-400/60 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
{qs.label}
|
||||
</button>
|
||||
{/each}
|
||||
<a
|
||||
href="https://novelfire.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700/50 text-zinc-400 border border-zinc-600/50 hover:text-amber-300 hover:border-amber-400/40 transition-colors"
|
||||
<input
|
||||
type="number"
|
||||
bind:value={rangeFrom}
|
||||
min="1"
|
||||
placeholder="From"
|
||||
class="w-20 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={rangeTo}
|
||||
min="1"
|
||||
placeholder="To"
|
||||
class="w-20 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<button
|
||||
onclick={triggerRangeScrape}
|
||||
disabled={!rangeUrl.trim() || rangeFrom === null || running || ranging}
|
||||
class="shrink-0 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Browse novelfire.net ↗
|
||||
</a>
|
||||
{ranging ? m.admin_scrape_queuing() : 'Go'}
|
||||
</button>
|
||||
{#if rangeError}<span class="text-xs text-(--color-danger) w-full pl-40">{rangeError}</span>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Quick genre chips -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 bg-(--color-surface) flex-wrap">
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_quick_genres()}</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each quickScrapes as qs}
|
||||
<button
|
||||
onclick={() => { scrapeUrl = qs.url; }}
|
||||
class="px-2.5 py-1 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) hover:border-(--color-brand)/50 hover:text-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
{qs.label}
|
||||
</button>
|
||||
{/each}
|
||||
<a
|
||||
href="https://novelfire.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-2.5 py-1 rounded text-xs font-medium text-(--color-muted) border border-(--color-border)/50 hover:text-(--color-brand-dim) hover:border-(--color-brand)/40 transition-colors"
|
||||
>
|
||||
novelfire.net ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tasks table -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h2 class="text-lg font-semibold text-zinc-100 flex-1">Task history</h2>
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) flex-1 uppercase tracking-widest">{m.admin_scrape_task_history()}</h2>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={q}
|
||||
placeholder="Filter by kind, status or URL…"
|
||||
class="w-full max-w-xs bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
placeholder={m.admin_scrape_filter_placeholder()}
|
||||
class="w-full max-w-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="text-zinc-500 text-sm py-8 text-center">
|
||||
{q.trim() ? 'No matching tasks.' : 'No scrape tasks yet.'}
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">
|
||||
{q.trim() ? m.admin_scrape_no_matching() : m.admin_tasks_empty()}
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700">
|
||||
<div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
|
||||
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Kind / URL</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
@@ -385,14 +365,14 @@
|
||||
<th class="px-4 py-3 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-700/50">
|
||||
<tbody class="divide-y divide-(--color-border)/50">
|
||||
{#each filtered as task}
|
||||
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
|
||||
<td class="px-4 py-3 font-mono text-xs text-zinc-300">
|
||||
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
|
||||
<td class="px-4 py-3 font-mono text-xs text-(--color-text)">
|
||||
{task.kind}
|
||||
{#if task.target_url}
|
||||
<br />
|
||||
<span class="text-zinc-500 truncate max-w-[16rem] block" title={task.target_url}>
|
||||
<span class="text-(--color-muted) truncate max-w-[16rem] block" title={task.target_url}>
|
||||
{task.target_url.replace('https://novelfire.net/book/', '')}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -400,21 +380,21 @@
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium {statusColor(task.status)}">{task.status}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-zinc-300">{task.books_found ?? 0}</td>
|
||||
<td class="px-4 py-3 text-right text-zinc-300">{task.chapters_scraped ?? 0}</td>
|
||||
<td class="px-4 py-3 text-right text-zinc-400">{task.chapters_skipped ?? 0}</td>
|
||||
<td class="px-4 py-3 text-right {task.errors > 0 ? 'text-red-400' : 'text-zinc-400'}">{task.errors ?? 0}</td>
|
||||
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(task.started)}</td>
|
||||
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{duration(task.started, task.finished)}</td>
|
||||
<td class="px-4 py-3 text-right text-(--color-text)">{task.books_found ?? 0}</td>
|
||||
<td class="px-4 py-3 text-right text-(--color-text)">{task.chapters_scraped ?? 0}</td>
|
||||
<td class="px-4 py-3 text-right text-(--color-muted)">{task.chapters_skipped ?? 0}</td>
|
||||
<td class="px-4 py-3 text-right {task.errors > 0 ? 'text-(--color-danger)' : 'text-(--color-muted)'}">{task.errors ?? 0}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(task.started)}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{duration(task.started, task.finished)}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#if task.status === 'pending'}
|
||||
<button
|
||||
onclick={() => cancelTask(task.id)}
|
||||
disabled={cancellingIds.has(task.id)}
|
||||
class="px-2 py-1 rounded text-xs font-medium bg-zinc-700 text-zinc-300 hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
|
||||
class="px-2 py-1 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-text) hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel'}
|
||||
{cancellingIds.has(task.id) ? 'Cancelling…' : m.admin_scrape_cancel()}
|
||||
</button>
|
||||
{/if}
|
||||
{#if task.kind === 'book_range' && task.status !== 'pending' && task.status !== 'running' && (task.chapters_scraped ?? 0) > 0}
|
||||
@@ -434,14 +414,14 @@
|
||||
</button>
|
||||
{/if}
|
||||
{#if cancelErrors[task.id]}
|
||||
<p class="text-xs text-red-400 mt-1 w-full">{cancelErrors[task.id]}</p>
|
||||
<p class="text-xs text-(--color-danger) mt-1 w-full">{cancelErrors[task.id]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{#if task.error_message}
|
||||
<tr class="bg-red-950/20">
|
||||
<td colspan="9" class="px-4 py-2 text-xs text-red-400 font-mono">{task.error_message}</td>
|
||||
<tr class="bg-(--color-danger)/10">
|
||||
<td colspan="9" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{task.error_message}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -452,12 +432,12 @@
|
||||
<!-- Mobile cards -->
|
||||
<div class="sm:hidden space-y-3">
|
||||
{#each filtered as task}
|
||||
<div class="bg-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2">
|
||||
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<span class="font-mono text-xs text-zinc-300">{task.kind}</span>
|
||||
<span class="font-mono text-xs text-(--color-text)">{task.kind}</span>
|
||||
{#if task.target_url}
|
||||
<p class="text-xs text-zinc-500 truncate mt-0.5" title={task.target_url}>
|
||||
<p class="text-xs text-(--color-muted) truncate mt-0.5" title={task.target_url}>
|
||||
{task.target_url.replace('https://novelfire.net/book/', '')}
|
||||
</p>
|
||||
{/if}
|
||||
@@ -465,24 +445,24 @@
|
||||
<span class="shrink-0 text-xs font-semibold {statusColor(task.status)}">{task.status}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1 text-xs">
|
||||
<span class="text-zinc-500">Books</span><span class="text-zinc-300 text-right">{task.books_found ?? 0}</span>
|
||||
<span class="text-zinc-500">Chapters</span><span class="text-zinc-300 text-right">{task.chapters_scraped ?? 0}</span>
|
||||
<span class="text-zinc-500">Skipped</span><span class="text-zinc-400 text-right">{task.chapters_skipped ?? 0}</span>
|
||||
<span class="text-zinc-500">Errors</span><span class="{task.errors > 0 ? 'text-red-400' : 'text-zinc-400'} text-right">{task.errors ?? 0}</span>
|
||||
<span class="text-zinc-500">Started</span><span class="text-zinc-400 text-right">{fmtDate(task.started)}</span>
|
||||
<span class="text-zinc-500">Duration</span><span class="text-zinc-400 text-right">{duration(task.started, task.finished)}</span>
|
||||
<span class="text-(--color-muted)">Books</span><span class="text-(--color-text) text-right">{task.books_found ?? 0}</span>
|
||||
<span class="text-(--color-muted)">Chapters</span><span class="text-(--color-text) text-right">{task.chapters_scraped ?? 0}</span>
|
||||
<span class="text-(--color-muted)">Skipped</span><span class="text-(--color-muted) text-right">{task.chapters_skipped ?? 0}</span>
|
||||
<span class="text-(--color-muted)">Errors</span><span class="{task.errors > 0 ? 'text-(--color-danger)' : 'text-(--color-muted)'} text-right">{task.errors ?? 0}</span>
|
||||
<span class="text-(--color-muted)">Started</span><span class="text-(--color-muted) text-right">{fmtDate(task.started)}</span>
|
||||
<span class="text-(--color-muted)">Duration</span><span class="text-(--color-muted) text-right">{duration(task.started, task.finished)}</span>
|
||||
</div>
|
||||
{#if task.error_message}
|
||||
<p class="text-xs text-red-400 font-mono break-all">{task.error_message}</p>
|
||||
<p class="text-xs text-(--color-danger) font-mono break-all">{task.error_message}</p>
|
||||
{/if}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if task.status === 'pending'}
|
||||
<button
|
||||
onclick={() => cancelTask(task.id)}
|
||||
disabled={cancellingIds.has(task.id)}
|
||||
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-300 hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
|
||||
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-(--color-surface-3) text-(--color-text) hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel task'}
|
||||
{cancellingIds.has(task.id) ? 'Cancelling…' : m.admin_scrape_cancel()} task
|
||||
</button>
|
||||
{/if}
|
||||
{#if task.kind === 'book_range' && task.status !== 'pending' && task.status !== 'running' && (task.chapters_scraped ?? 0) > 0}
|
||||
@@ -502,7 +482,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
{#if cancelErrors[task.id]}
|
||||
<p class="text-xs text-red-400 w-full">{cancelErrors[task.id]}</p>
|
||||
<p class="text-xs text-(--color-danger) w-full">{cancelErrors[task.id]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
63
ui/src/routes/admin/translation/+page.server.ts
Normal file
63
ui/src/routes/admin/translation/+page.server.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { listBooks, listTranslationJobs, type TranslationJob } from '$lib/server/pocketbase';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (locals.user?.role !== 'admin') {
|
||||
redirect(302, '/');
|
||||
}
|
||||
|
||||
const [books, jobs] = await Promise.all([
|
||||
listBooks().catch((e): Awaited<ReturnType<typeof listBooks>> => {
|
||||
log.warn('admin/translation', 'failed to load books', { err: String(e) });
|
||||
return [];
|
||||
}),
|
||||
listTranslationJobs().catch((e): TranslationJob[] => {
|
||||
log.warn('admin/translation', 'failed to load translation jobs', { err: String(e) });
|
||||
return [];
|
||||
})
|
||||
]);
|
||||
|
||||
return { books, jobs };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
bulk: async ({ request, locals }) => {
|
||||
if (locals.user?.role !== 'admin') {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const slug = form.get('slug')?.toString().trim() ?? '';
|
||||
const lang = form.get('lang')?.toString().trim() ?? '';
|
||||
const from = parseInt(form.get('from')?.toString() ?? '1', 10);
|
||||
const to = parseInt(form.get('to')?.toString() ?? '1', 10);
|
||||
|
||||
if (!slug || !lang) {
|
||||
return { success: false, error: 'slug and lang are required' };
|
||||
}
|
||||
if (isNaN(from) || isNaN(to) || from < 1 || to < from) {
|
||||
return { success: false, error: 'Invalid chapter range' };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await backendFetch('/api/admin/translation/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, lang, from, to })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('admin/translation', 'bulk enqueue failed', { status: res.status, body });
|
||||
return { success: false, error: `Backend error ${res.status}: ${body}` };
|
||||
}
|
||||
const data = await res.json();
|
||||
return { success: true, enqueued: data.enqueued as number };
|
||||
} catch (e) {
|
||||
log.error('admin/translation', 'bulk enqueue fetch error', { err: String(e) });
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
};
|
||||
340
ui/src/routes/admin/translation/+page.svelte
Normal file
340
ui/src/routes/admin/translation/+page.svelte
Normal file
@@ -0,0 +1,340 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import type { TranslationJob } from '$lib/server/pocketbase';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let jobs = $state<TranslationJob[]>(untrack(() => data.jobs));
|
||||
|
||||
// Keep in sync on server reloads
|
||||
$effect(() => {
|
||||
jobs = data.jobs;
|
||||
});
|
||||
|
||||
// ── Live-poll while any job is in-flight ─────────────────────────────────────
|
||||
let hasInFlight = $derived(jobs.some((j) => j.status === 'pending' || j.status === 'running'));
|
||||
|
||||
$effect(() => {
|
||||
if (!hasInFlight) return;
|
||||
const id = setInterval(() => {
|
||||
invalidateAll();
|
||||
}, 3000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
type Tab = 'enqueue' | 'jobs';
|
||||
let activeTab = $state<Tab>('enqueue');
|
||||
|
||||
// ── Bulk enqueue form ─────────────────────────────────────────────────────────
|
||||
let slugInput = $state('');
|
||||
let langInput = $state('ru');
|
||||
let fromInput = $state(1);
|
||||
let toInput = $state(1);
|
||||
let submitting = $state(false);
|
||||
|
||||
const langs = [
|
||||
{ value: 'ru', label: 'Russian (ru)' },
|
||||
{ value: 'id', label: 'Indonesian (id)' },
|
||||
{ value: 'pt', label: 'Portuguese (pt)' },
|
||||
{ value: 'fr', label: 'French (fr)' }
|
||||
];
|
||||
|
||||
// ── Jobs helpers ──────────────────────────────────────────────────────────────
|
||||
function jobStatusColor(status: string) {
|
||||
if (status === 'done') return 'text-green-400';
|
||||
if (status === 'running') return 'text-(--color-brand) animate-pulse';
|
||||
if (status === 'pending') return 'text-sky-400 animate-pulse';
|
||||
if (status === 'failed') return 'text-(--color-danger)';
|
||||
return 'text-(--color-text)';
|
||||
}
|
||||
|
||||
function fmtDate(s: string) {
|
||||
if (!s || s.startsWith('0001')) return '—';
|
||||
return new Date(s).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function duration(started: string, finished: string) {
|
||||
if (!started || !finished || started.startsWith('0001') || finished.startsWith('0001'))
|
||||
return '—';
|
||||
const ms = new Date(finished).getTime() - new Date(started).getTime();
|
||||
if (ms < 0) return '—';
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
let jobsQ = $state('');
|
||||
let filteredJobs = $derived(
|
||||
jobsQ.trim()
|
||||
? jobs.filter(
|
||||
(j) =>
|
||||
j.slug.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
|
||||
j.lang.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
|
||||
j.status.toLowerCase().includes(jobsQ.toLowerCase().trim())
|
||||
)
|
||||
: jobs
|
||||
);
|
||||
|
||||
let stats = $derived({
|
||||
total: jobs.length,
|
||||
done: jobs.filter((j) => j.status === 'done').length,
|
||||
failed: jobs.filter((j) => j.status === 'failed').length,
|
||||
inFlight: jobs.filter((j) => j.status === 'pending' || j.status === 'running').length
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Translation — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">Machine Translation</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
{stats.total} job{stats.total !== 1 ? 's' : ''} ·
|
||||
<span class="text-green-400">{stats.done} done</span>
|
||||
{#if stats.failed > 0}
|
||||
· <span class="text-(--color-danger)">{stats.failed} failed</span>
|
||||
{/if}
|
||||
{#if stats.inFlight > 0}
|
||||
· <span class="text-(--color-brand) animate-pulse">{stats.inFlight} in-flight</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
|
||||
<button
|
||||
onclick={() => (activeTab = 'enqueue')}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{activeTab === 'enqueue' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Enqueue
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'jobs')}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{activeTab === 'jobs' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Jobs
|
||||
{#if stats.inFlight > 0}
|
||||
<span
|
||||
class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[10px] font-bold"
|
||||
>
|
||||
{stats.inFlight}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Enqueue tab ─────────────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'enqueue'}
|
||||
<div class="max-w-lg space-y-5">
|
||||
<!-- Result banner -->
|
||||
{#if form?.success}
|
||||
<div class="rounded-lg border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-400">
|
||||
Enqueued {form.enqueued} translation job{form.enqueued !== 1 ? 's' : ''} successfully.
|
||||
</div>
|
||||
{:else if form?.error}
|
||||
<div class="rounded-lg border border-(--color-danger)/40 bg-(--color-danger)/10 px-4 py-3 text-sm text-(--color-danger)">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/bulk"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
submitting = false;
|
||||
activeTab = 'jobs';
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- Book slug -->
|
||||
<div class="space-y-1">
|
||||
<label for="slug" class="block text-sm font-medium text-(--color-text)">Book slug</label>
|
||||
<input
|
||||
id="slug"
|
||||
name="slug"
|
||||
type="text"
|
||||
required
|
||||
list="book-slugs"
|
||||
bind:value={slugInput}
|
||||
placeholder="e.g. the-beginning-after-the-end"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<datalist id="book-slugs">
|
||||
{#each data.books as book}
|
||||
<option value={book.slug}>{book.title}</option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<!-- Language -->
|
||||
<div class="space-y-1">
|
||||
<label for="lang" class="block text-sm font-medium text-(--color-text)">Target language</label>
|
||||
<select
|
||||
id="lang"
|
||||
name="lang"
|
||||
bind:value={langInput}
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
>
|
||||
{#each langs as l}
|
||||
<option value={l.value}>{l.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Chapter range -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1">
|
||||
<label for="from" class="block text-sm font-medium text-(--color-text)">From chapter</label>
|
||||
<input
|
||||
id="from"
|
||||
name="from"
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
bind:value={fromInput}
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label for="to" class="block text-sm font-medium text-(--color-text)">To chapter</label>
|
||||
<input
|
||||
id="to"
|
||||
name="to"
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
bind:value={toInput}
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
Enqueues {Math.max(0, toInput - fromInput + 1)} task{toInput - fromInput + 1 !== 1 ? 's' : ''} — one per chapter. Max 1000 at a time.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="w-full sm:w-auto px-6 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? 'Enqueueing…' : 'Enqueue translations'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Jobs tab ───────────────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'jobs'}
|
||||
<input
|
||||
type="search"
|
||||
bind:value={jobsQ}
|
||||
placeholder="Filter by slug, lang, or status…"
|
||||
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
|
||||
{#if filteredJobs.length === 0}
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">
|
||||
{jobsQ.trim() ? 'No matching jobs.' : 'No translation jobs yet.'}
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Book</th>
|
||||
<th class="px-4 py-3 text-right">Ch.</th>
|
||||
<th class="px-4 py-3 text-left">Lang</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
<th class="px-4 py-3 text-left">Started</th>
|
||||
<th class="px-4 py-3 text-left">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-(--color-border)/50">
|
||||
{#each filteredJobs as job}
|
||||
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
|
||||
<td class="px-4 py-3 text-(--color-text) font-medium">
|
||||
<a href="/books/{job.slug}" class="hover:text-(--color-brand) transition-colors"
|
||||
>{job.slug}</a
|
||||
>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs uppercase">{job.lang}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap"
|
||||
>{duration(job.started, job.finished)}</td
|
||||
>
|
||||
</tr>
|
||||
{#if job.error_message}
|
||||
<tr class="bg-(--color-danger)/10">
|
||||
<td colspan="6" class="px-4 py-2 text-xs text-(--color-danger) font-mono"
|
||||
>{job.error_message}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div class="sm:hidden space-y-3">
|
||||
{#each filteredJobs as job}
|
||||
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<a
|
||||
href="/books/{job.slug}"
|
||||
class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors truncate"
|
||||
>
|
||||
{job.slug}
|
||||
</a>
|
||||
<span class="shrink-0 text-xs font-semibold {jobStatusColor(job.status)}"
|
||||
>{job.status}</span
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1 text-xs">
|
||||
<span class="text-(--color-muted)">Chapter</span><span
|
||||
class="text-(--color-muted) text-right">{job.chapter}</span
|
||||
>
|
||||
<span class="text-(--color-muted)">Lang</span><span
|
||||
class="text-(--color-muted) font-mono text-right uppercase">{job.lang}</span
|
||||
>
|
||||
<span class="text-(--color-muted)">Started</span><span
|
||||
class="text-(--color-muted) text-right">{fmtDate(job.started)}</span
|
||||
>
|
||||
<span class="text-(--color-muted)">Duration</span><span
|
||||
class="text-(--color-muted) text-right">{duration(job.started, job.finished)}</span
|
||||
>
|
||||
</div>
|
||||
{#if job.error_message}
|
||||
<p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -2,6 +2,34 @@ import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import * as cache from '$lib/server/cache';
|
||||
|
||||
const FREE_DAILY_AUDIO_LIMIT = 3;
|
||||
|
||||
/**
|
||||
* Return the number of audio chapters a user/session has generated today,
|
||||
* and increment the counter. Uses a Valkey key that expires at midnight UTC.
|
||||
*
|
||||
* Key: audio:daily:<userId|sessionId>:<YYYY-MM-DD>
|
||||
*/
|
||||
async function incrementDailyAudioCount(identifier: string): Promise<number> {
|
||||
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const key = `audio:daily:${identifier}:${today}`;
|
||||
// Seconds until end of day UTC
|
||||
const now = new Date();
|
||||
const endOfDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
|
||||
const ttl = Math.ceil((endOfDay.getTime() - now.getTime()) / 1000);
|
||||
// Use raw get/set with increment so we can read + increment atomically
|
||||
try {
|
||||
const raw = await cache.get<number>(key);
|
||||
const current = (raw ?? 0) + 1;
|
||||
await cache.set(key, current, ttl);
|
||||
return current;
|
||||
} catch {
|
||||
// On cache failure, fail open (don't block audio for cache errors)
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/audio/[slug]/[n]
|
||||
@@ -15,14 +43,39 @@ import { backendFetch } from '$lib/server/scraper';
|
||||
* GET /api/presign/audio to obtain a direct MinIO presigned URL.
|
||||
* 202 { task_id: string, status: "pending"|"generating" } — generation
|
||||
* enqueued; poll GET /api/audio/status/[slug]/[n]?voice=... until done.
|
||||
* 402 { error: "pro_required", limit: 3 } — free daily limit reached.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ params, request }) => {
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const { slug, n } = params;
|
||||
const chapter = parseInt(n, 10);
|
||||
if (!slug || !chapter || chapter < 1) {
|
||||
error(400, 'Invalid slug or chapter number');
|
||||
}
|
||||
|
||||
// ── Paywall: 3 audio chapters/day for free users ───────────────────────────
|
||||
if (!locals.isPro) {
|
||||
// Check if audio already exists (cached) before counting — no charge for
|
||||
// re-requesting something already generated
|
||||
const statusRes = await backendFetch(
|
||||
`/api/audio/status/${slug}/${chapter}`
|
||||
).catch(() => null);
|
||||
const statusData = statusRes?.ok
|
||||
? ((await statusRes.json().catch(() => ({}))) as { status?: string })
|
||||
: {};
|
||||
|
||||
if (statusData.status !== 'done') {
|
||||
const identifier = locals.user?.id ?? locals.sessionId;
|
||||
const count = await incrementDailyAudioCount(identifier);
|
||||
if (count > FREE_DAILY_AUDIO_LIMIT) {
|
||||
log.info('polar', 'free audio limit reached', { identifier, count });
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'pro_required', limit: FREE_DAILY_AUDIO_LIMIT }),
|
||||
{ status: 402, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let body: { voice?: string } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
@@ -62,4 +115,3 @@ export const POST: RequestHandler = async ({ params, request }) => {
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getUserByUsername } from '$lib/server/pocketbase';
|
||||
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
@@ -13,10 +14,11 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
}
|
||||
// Fetch full record from PocketBase to get avatar_url
|
||||
const record = await getUserByUsername(locals.user.username).catch(() => null);
|
||||
const avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url).catch(() => null);
|
||||
return json({
|
||||
id: locals.user.id,
|
||||
username: locals.user.username,
|
||||
role: locals.user.role,
|
||||
avatar_url: record?.avatar_url ?? null
|
||||
avatar_url: avatarUrl
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
listReplies,
|
||||
createComment,
|
||||
getMyVotes,
|
||||
getUserById,
|
||||
type CommentSort
|
||||
} from '$lib/server/pocketbase';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
@@ -38,13 +39,15 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
replies: repliesPerComment[i]
|
||||
}));
|
||||
|
||||
// Batch-resolve avatar presign URLs for all unique user_ids
|
||||
// Batch-resolve avatar URLs for all unique user_ids
|
||||
// MinIO first (custom upload), fall back to OAuth provider picture.
|
||||
const allComments = [...topLevel, ...allReplies];
|
||||
const uniqueUserIds = [...new Set(allComments.map((c) => c.user_id).filter(Boolean))];
|
||||
const avatarEntries = await Promise.all(
|
||||
uniqueUserIds.map(async (userId) => {
|
||||
try {
|
||||
const url = await presignAvatarUrl(userId);
|
||||
const user = await getUserById(userId);
|
||||
const url = await resolveAvatarUrl(userId, user?.avatar_url);
|
||||
return [userId, url] as [string, string | null];
|
||||
} catch {
|
||||
return [userId, null] as [string, null];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { presignAvatarUrl, resolveAvatarUrl } from '$lib/server/minio';
|
||||
import { updateUserAvatarUrl, getUserByUsername } from '$lib/server/pocketbase';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
@@ -63,10 +63,6 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user) error(401, 'Not authenticated');
|
||||
|
||||
const record = await getUserByUsername(locals.user.username).catch(() => null);
|
||||
if (!record?.avatar_url) {
|
||||
return json({ avatar_url: null });
|
||||
}
|
||||
|
||||
const avatarUrl = await presignAvatarUrl(locals.user.id);
|
||||
const avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url).catch(() => null);
|
||||
return json({ avatar_url: avatarUrl });
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/settings
|
||||
* Returns the current user's settings (auto_next, voice, speed).
|
||||
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize).
|
||||
* Returns defaults if no settings record exists yet.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
@@ -14,7 +14,11 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
return json({
|
||||
autoNext: settings?.auto_next ?? false,
|
||||
voice: settings?.voice ?? 'af_bella',
|
||||
speed: settings?.speed ?? 1.0
|
||||
speed: settings?.speed ?? 1.0,
|
||||
theme: settings?.theme ?? 'amber',
|
||||
locale: settings?.locale ?? 'en',
|
||||
fontFamily: settings?.font_family ?? 'system',
|
||||
fontSize: settings?.font_size ?? 1.0
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('settings', 'GET failed', { err: String(e) });
|
||||
@@ -24,7 +28,7 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
|
||||
/**
|
||||
* PUT /api/settings
|
||||
* Body: { autoNext: boolean, voice: string, speed: number }
|
||||
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number }
|
||||
* Saves user preferences.
|
||||
*/
|
||||
export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
@@ -39,6 +43,30 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
error(400, 'Invalid body — expected { autoNext, voice, speed }');
|
||||
}
|
||||
|
||||
// theme is optional — if provided it must be a known value
|
||||
const validThemes = ['amber', 'slate', 'rose', 'light', 'light-slate', 'light-rose'];
|
||||
if (body.theme !== undefined && !validThemes.includes(body.theme)) {
|
||||
error(400, `Invalid theme — must be one of: ${validThemes.join(', ')}`);
|
||||
}
|
||||
|
||||
// locale is optional — if provided it must be a known value
|
||||
const validLocales = ['en', 'ru', 'id', 'pt', 'fr'];
|
||||
if (body.locale !== undefined && !validLocales.includes(body.locale)) {
|
||||
error(400, `Invalid locale — must be one of: ${validLocales.join(', ')}`);
|
||||
}
|
||||
|
||||
// fontFamily is optional — if provided it must be a known value
|
||||
const validFontFamilies = ['system', 'serif', 'mono'];
|
||||
if (body.fontFamily !== undefined && !validFontFamilies.includes(body.fontFamily)) {
|
||||
error(400, `Invalid fontFamily — must be one of: ${validFontFamilies.join(', ')}`);
|
||||
}
|
||||
|
||||
// fontSize is optional — if provided it must be one of the valid steps
|
||||
const validFontSizes = [0.9, 1.0, 1.15, 1.3];
|
||||
if (body.fontSize !== undefined && !validFontSizes.includes(body.fontSize)) {
|
||||
error(400, `Invalid fontSize — must be one of: ${validFontSizes.join(', ')}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await saveSettings(locals.sessionId, body, locals.user?.id);
|
||||
} catch (e) {
|
||||
|
||||
69
ui/src/routes/api/translation/[slug]/[n]/+server.ts
Normal file
69
ui/src/routes/api/translation/[slug]/[n]/+server.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
const SUPPORTED_LANGS = new Set(['ru', 'id', 'pt', 'fr']);
|
||||
|
||||
/**
|
||||
* POST /api/translation/[slug]/[n]?lang=<lang>
|
||||
* Proxy to backend translation enqueue endpoint.
|
||||
* Enforces Pro gate — free users cannot enqueue translations.
|
||||
*
|
||||
* GET /api/translation/[slug]/[n]?lang=<lang>
|
||||
* Proxy to backend translation fetch (no gate — already gated at page.server.ts).
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const { slug, n } = params;
|
||||
const lang = url.searchParams.get('lang') ?? '';
|
||||
const res = await backendFetch(
|
||||
`/api/translation/${encodeURIComponent(slug)}/${n}?lang=${lang}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
return new Response(null, { status: res.status });
|
||||
}
|
||||
const data = await res.json();
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ params, url, locals }) => {
|
||||
const { slug, n } = params;
|
||||
const chapter = parseInt(n, 10);
|
||||
const lang = url.searchParams.get('lang') ?? '';
|
||||
|
||||
if (!slug || !chapter || chapter < 1) error(400, 'Invalid slug or chapter');
|
||||
if (!SUPPORTED_LANGS.has(lang)) error(400, 'Unsupported language');
|
||||
|
||||
// ── Pro gate ──────────────────────────────────────────────────────────────
|
||||
if (!locals.isPro) {
|
||||
log.info('polar', 'translation blocked for free user', {
|
||||
userId: locals.user?.id,
|
||||
slug,
|
||||
chapter,
|
||||
lang
|
||||
});
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'pro_required' }),
|
||||
{ status: 402, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const res = await backendFetch(
|
||||
`/api/translation/${encodeURIComponent(slug)}/${chapter}?lang=${lang}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
log.error('translation', 'backend translation enqueue failed', { slug, chapter, lang, status: res.status, body: text });
|
||||
error(res.status as Parameters<typeof error>[0], text || 'Translation enqueue failed');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
};
|
||||
23
ui/src/routes/api/translation/status/[slug]/[n]/+server.ts
Normal file
23
ui/src/routes/api/translation/status/[slug]/[n]/+server.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
/**
|
||||
* GET /api/translation/status/[slug]/[n]?lang=<lang>
|
||||
* Proxies the translation status check to the backend.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
const { slug, n } = params;
|
||||
const lang = url.searchParams.get('lang') ?? '';
|
||||
const res = await backendFetch(
|
||||
`/api/translation/status/${encodeURIComponent(slug)}/${n}?lang=${lang}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
return new Response(JSON.stringify({ status: 'idle' }), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
const data = await res.json();
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getPublicProfile, getSubscription } from '$lib/server/pocketbase';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { resolveAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
@@ -15,11 +15,9 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const profile = await getPublicProfile(username);
|
||||
if (!profile) error(404, `User "${username}" not found`);
|
||||
|
||||
// Resolve avatar presigned URL if set
|
||||
// Resolve avatar — MinIO first, fall back to OAuth provider picture
|
||||
let avatarUrl: string | null = null;
|
||||
if (profile.avatar_url) {
|
||||
avatarUrl = await presignAvatarUrl(profile.id).catch(() => null);
|
||||
}
|
||||
avatarUrl = await resolveAvatarUrl(profile.id, profile.avatar_url).catch(() => null);
|
||||
|
||||
// Is the current logged-in user subscribed?
|
||||
let isSubscribed = false;
|
||||
|
||||
52
ui/src/routes/api/webhooks/polar/+server.ts
Normal file
52
ui/src/routes/api/webhooks/polar/+server.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { verifyPolarWebhook, handleSubscriptionEvent } from '$lib/server/polar';
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/polar
|
||||
*
|
||||
* Receives Polar subscription lifecycle events and syncs user roles in PocketBase.
|
||||
* Signature is verified via HMAC-SHA256 before any processing.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const rawBody = await request.text();
|
||||
const signature = request.headers.get('webhook-signature') ?? '';
|
||||
|
||||
if (!verifyPolarWebhook(rawBody, signature)) {
|
||||
log.warn('polar', 'webhook signature verification failed');
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
let event: { type: string; data: Record<string, unknown> };
|
||||
try {
|
||||
event = JSON.parse(rawBody);
|
||||
} catch {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const { type, data } = event;
|
||||
log.info('polar', 'webhook received', { type });
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case 'subscription.created':
|
||||
case 'subscription.updated':
|
||||
case 'subscription.revoked':
|
||||
await handleSubscriptionEvent(type, data as unknown as Parameters<typeof handleSubscriptionEvent>[1]);
|
||||
break;
|
||||
|
||||
case 'order.created':
|
||||
// One-time purchases — no role change needed for now
|
||||
log.info('polar', 'order.created (no action)', { orderId: data.id });
|
||||
break;
|
||||
|
||||
default:
|
||||
log.debug('polar', 'unhandled webhook event type', { type });
|
||||
}
|
||||
} catch (err) {
|
||||
// Log but return 200 — Polar retries on non-2xx, we don't want retry storms
|
||||
log.error('polar', 'webhook handler error', { type, err: String(err) });
|
||||
}
|
||||
|
||||
return new Response('OK', { status: 200 });
|
||||
};
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
linkOAuthToUser
|
||||
} from '$lib/server/pocketbase';
|
||||
import { createAuthToken } from '../../../../hooks.server';
|
||||
import { createUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
|
||||
import { createUserSession, touchUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
type Provider = 'google' | 'github';
|
||||
@@ -227,12 +227,21 @@ export const GET: RequestHandler = async ({ params, url, cookies, locals }) => {
|
||||
);
|
||||
|
||||
// ── Create session + auth cookie ──────────────────────────────────────────
|
||||
const authSessionId = randomBytes(16).toString('hex');
|
||||
const userAgent = '' ; // not available in RequestHandler — omit
|
||||
const ip = '';
|
||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
|
||||
log.warn('oauth', 'createUserSession failed (non-fatal)', { err: String(err) })
|
||||
);
|
||||
let authSessionId: string;
|
||||
|
||||
// Reuse existing session if the user is already logged in as the same user
|
||||
if (locals.user?.id === user.id && locals.user?.authSessionId) {
|
||||
authSessionId = locals.user.authSessionId;
|
||||
// Just touch the existing session to update last_seen
|
||||
touchUserSession(authSessionId).catch(() => {});
|
||||
} else {
|
||||
authSessionId = randomBytes(16).toString('hex');
|
||||
const userAgent = ''; // not available in RequestHandler — omit
|
||||
const ip = '';
|
||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
|
||||
log.warn('oauth', 'createUserSession failed (non-fatal)', { err: String(err) })
|
||||
);
|
||||
}
|
||||
|
||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||
cookies.set(AUTH_COOKIE, token, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -16,23 +17,23 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Library — libnovel</title>
|
||||
<title>{m.books_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Library</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">
|
||||
{data.books?.length ?? 0} book{(data.books?.length ?? 0) !== 1 ? 's' : ''}
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">{m.books_heading()}</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
{m.books_count({ n: String(data.books?.length ?? 0), s: (data.books?.length ?? 0) !== 1 ? 's' : '' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !data.books?.length}
|
||||
<div class="text-center py-20 text-zinc-500">
|
||||
<p class="text-lg">Your library is empty.</p>
|
||||
<div class="text-center py-20 text-(--color-muted)">
|
||||
<p class="text-lg">{m.books_empty_library()}</p>
|
||||
<p class="text-sm mt-2">
|
||||
Books you start reading or save from
|
||||
<a href="/catalogue" class="text-amber-400 hover:text-amber-300 transition-colors">Discover</a>
|
||||
will appear here.
|
||||
{m.books_empty_discover()}
|
||||
<a href="/catalogue" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">{m.books_empty_discover_link()}</a>
|
||||
{m.books_empty_discover_suffix()}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -42,10 +43,10 @@
|
||||
{@const genres = parseGenres(book.genres)}
|
||||
<a
|
||||
href="/books/{book.slug}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
|
||||
>
|
||||
<!-- Cover image -->
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden">
|
||||
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
|
||||
{#if book.cover}
|
||||
<img
|
||||
src={book.cover}
|
||||
@@ -54,7 +55,7 @@
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<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" />
|
||||
@@ -65,21 +66,21 @@
|
||||
|
||||
<!-- Info -->
|
||||
<div class="p-2 flex flex-col gap-1 flex-1">
|
||||
<h2 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">
|
||||
<h2 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">
|
||||
{book.title ?? ''}
|
||||
</h2>
|
||||
{#if book.author}
|
||||
<p class="text-xs text-zinc-400 truncate">{book.author ?? ''}</p>
|
||||
<p class="text-xs text-(--color-muted) truncate">{book.author ?? ''}</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-auto pt-1 flex items-center justify-between gap-1">
|
||||
{#if book.status}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300 truncate max-w-[60%]">
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-text) truncate max-w-[60%]">
|
||||
{book.status}
|
||||
</span>
|
||||
{/if}
|
||||
{#if lastChapter}
|
||||
<span class="text-xs text-amber-400 font-medium ml-auto whitespace-nowrap">
|
||||
<span class="text-xs text-(--color-brand) font-medium ml-auto whitespace-nowrap">
|
||||
ch.{lastChapter}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -88,7 +89,7 @@
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{#each genres.slice(0, 2) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -38,8 +39,6 @@
|
||||
}
|
||||
|
||||
const genres = $derived(parseGenres(data.book?.genres ?? []));
|
||||
|
||||
// Use chapters from loaded data (both library and preview paths return chapters now)
|
||||
const chapterList = $derived(data.chapters ?? []);
|
||||
|
||||
// ── Admin: rescrape ───────────────────────────────────────────────────────
|
||||
@@ -102,9 +101,6 @@
|
||||
let adminOpen = $state(false);
|
||||
|
||||
// ── Auto-poll when scrape task is in flight ───────────────────────────────
|
||||
// When the backend enqueues a scrape for an unseen book, the page shows a
|
||||
// spinner. Poll every 3 s until the task reaches "done" or "failed", then
|
||||
// reload the full page data so chapters appear automatically.
|
||||
$effect(() => {
|
||||
if (!data.scraping || !data.taskId) return;
|
||||
|
||||
@@ -130,26 +126,26 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.scraping ? 'Scraping…' : data.book?.title ?? 'Book'} — libnovel</title>
|
||||
<title>{data.scraping ? m.book_detail_scraping() : (data.book?.title ?? 'Book')} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if data.scraping}
|
||||
<!-- ═══════════════════════════════════════════ Scraping in progress ══ -->
|
||||
<div class="flex flex-col items-center justify-center py-24 gap-5 text-center">
|
||||
<svg class="w-10 h-10 text-amber-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<svg class="w-10 h-10 text-(--color-brand) animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-zinc-200 font-semibold text-lg">Scraping in progress…</p>
|
||||
<p class="text-zinc-500 text-sm mt-1">
|
||||
Fetching the first 20 chapters. This page will refresh automatically.
|
||||
<p class="text-(--color-text) font-semibold text-lg">{m.book_detail_scraping()}</p>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
{m.book_detail_scraping_progress()}
|
||||
</p>
|
||||
{#if data.taskId}
|
||||
<p class="text-zinc-600 text-xs mt-2 font-mono">task: {data.taskId}</p>
|
||||
{#if data.taskId && data.user?.role === 'admin'}
|
||||
<p class="text-(--color-muted) text-xs mt-2 font-mono">task: {data.taskId}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<a href="/" class="mt-2 text-sm text-amber-400 hover:text-amber-300 transition-colors">← Home</a>
|
||||
<a href="/" class="mt-2 text-sm text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">{m.book_detail_scraping_home()}</a>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
@@ -165,7 +161,7 @@
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-zinc-900/60 to-zinc-900/95 pointer-events-none" aria-hidden="true"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-(--color-surface)/60 to-(--color-surface)/95 pointer-events-none" aria-hidden="true"></div>
|
||||
|
||||
<div class="relative flex flex-col p-5 sm:p-7 gap-4">
|
||||
<!-- Cover + meta row -->
|
||||
@@ -175,7 +171,7 @@
|
||||
<img
|
||||
src={book.cover}
|
||||
alt={book.title}
|
||||
class="w-28 sm:w-48 rounded-lg object-cover flex-shrink-0 border border-zinc-700 shadow-xl self-start"
|
||||
class="w-28 sm:w-48 rounded-lg object-cover flex-shrink-0 border border-(--color-border) shadow-xl self-start"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -183,57 +179,57 @@
|
||||
<div class="flex flex-col gap-2 min-w-0 flex-1">
|
||||
<!-- Title + "not in library" badge -->
|
||||
<div class="flex items-start gap-2 flex-wrap">
|
||||
<h1 class="text-xl sm:text-3xl font-bold text-zinc-100 leading-tight">{book.title}</h1>
|
||||
<h1 class="text-xl sm:text-3xl font-bold text-(--color-text) leading-tight">{book.title}</h1>
|
||||
{#if !data.inLib}
|
||||
<span
|
||||
class="mt-1 text-xs px-2 py-0.5 rounded-full bg-zinc-700 text-zinc-400 border border-zinc-600 shrink-0"
|
||||
class="mt-1 text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) shrink-0"
|
||||
title="This book was fetched live from the source and is not yet in your library"
|
||||
>
|
||||
not in library
|
||||
{m.book_detail_not_in_library()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Author -->
|
||||
{#if book.author}
|
||||
<p class="text-zinc-400 text-sm">{book.author}</p>
|
||||
<p class="text-(--color-muted) text-sm">{book.author}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Status + genres -->
|
||||
<div class="flex flex-wrap gap-1.5 mt-0.5">
|
||||
{#if book.status}
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-zinc-700 text-zinc-300 border border-zinc-600">{book.status}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-3) text-(--color-text) border border-(--color-border)">{book.status}</span>
|
||||
{/if}
|
||||
{#each genres as genre}
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-zinc-800 text-zinc-400 border border-zinc-700">{genre}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border)">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Summary with expand toggle -->
|
||||
{#if book.summary}
|
||||
<div class="mt-1">
|
||||
<p class="text-zinc-400 text-sm leading-relaxed break-words {summaryExpanded ? '' : 'line-clamp-3'}">
|
||||
<p class="text-(--color-muted) text-sm leading-relaxed break-words {summaryExpanded ? '' : 'line-clamp-3'}">
|
||||
{book.summary}
|
||||
</p>
|
||||
{#if book.summary.length > 220}
|
||||
<button
|
||||
onclick={() => (summaryExpanded = !summaryExpanded)}
|
||||
class="text-xs text-amber-400/70 hover:text-amber-400 mt-1 transition-colors"
|
||||
class="text-xs text-(--color-brand)/70 hover:text-(--color-brand) mt-1 transition-colors"
|
||||
>
|
||||
{summaryExpanded ? 'Less' : 'More'}
|
||||
{summaryExpanded ? m.book_detail_less() : m.book_detail_more()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- CTA buttons — desktop only (hidden on mobile, shown below on mobile) -->
|
||||
<!-- CTA buttons — desktop only -->
|
||||
<div class="hidden sm:flex gap-2 mt-3 items-center flex-wrap">
|
||||
{#if data.lastChapter}
|
||||
<a
|
||||
href="/books/{book.slug}/chapters/{data.lastChapter}"
|
||||
class="px-5 py-2 bg-amber-400 text-zinc-900 font-semibold rounded-lg text-sm hover:bg-amber-300 transition-colors shadow"
|
||||
class="px-5 py-2 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg text-sm hover:bg-(--color-brand-dim) transition-colors shadow"
|
||||
>
|
||||
Continue ch.{data.lastChapter}
|
||||
{m.book_detail_continue_ch({ n: String(data.lastChapter) })}
|
||||
</a>
|
||||
{/if}
|
||||
{#if chapterList.length > 0}
|
||||
@@ -241,21 +237,31 @@
|
||||
href="/books/{book.slug}/chapters/1"
|
||||
class="px-4 py-2 rounded-lg text-sm font-semibold transition-colors
|
||||
{data.lastChapter
|
||||
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
|
||||
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300 shadow'}"
|
||||
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
|
||||
: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
|
||||
>
|
||||
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
|
||||
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.inLib}
|
||||
{#if !data.isLoggedIn}
|
||||
<a
|
||||
href="/login"
|
||||
title={m.book_detail_signin_to_save()}
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg border border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if data.inLib}
|
||||
<button
|
||||
onclick={toggleSave}
|
||||
disabled={saving}
|
||||
title={saved ? 'Remove from library' : 'Add to library'}
|
||||
title={saved ? m.book_detail_remove_from_library() : m.book_detail_add_to_library()}
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg border transition-colors disabled:opacity-50
|
||||
{saved
|
||||
? 'bg-amber-400/20 text-amber-300 border-amber-400/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
|
||||
: 'bg-zinc-700 text-zinc-400 border-zinc-600 hover:bg-zinc-600 hover:text-zinc-100'}"
|
||||
? 'bg-(--color-brand)/20 text-(--color-brand-dim) border-(--color-brand)/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
|
||||
: 'bg-(--color-surface-3) text-(--color-muted) border-(--color-border) hover:bg-(--color-surface-3) hover:text-(--color-text)'}"
|
||||
>
|
||||
{#if saving}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
@@ -277,14 +283,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA buttons — mobile only, full-width row below cover+meta -->
|
||||
<!-- CTA buttons — mobile only -->
|
||||
<div class="flex sm:hidden gap-2 items-center">
|
||||
{#if data.lastChapter}
|
||||
<a
|
||||
href="/books/{book.slug}/chapters/{data.lastChapter}"
|
||||
class="flex-1 text-center px-4 py-2.5 bg-amber-400 text-zinc-900 font-semibold rounded-lg text-sm hover:bg-amber-300 transition-colors shadow"
|
||||
class="flex-1 text-center px-4 py-2.5 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg text-sm hover:bg-(--color-brand-dim) transition-colors shadow"
|
||||
>
|
||||
Continue ch.{data.lastChapter}
|
||||
{m.book_detail_continue_ch({ n: String(data.lastChapter) })}
|
||||
</a>
|
||||
{/if}
|
||||
{#if chapterList.length > 0}
|
||||
@@ -292,21 +298,31 @@
|
||||
href="/books/{book.slug}/chapters/1"
|
||||
class="flex-1 text-center px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors
|
||||
{data.lastChapter
|
||||
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
|
||||
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300 shadow'}"
|
||||
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
|
||||
: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
|
||||
>
|
||||
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
|
||||
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.inLib}
|
||||
{#if !data.isLoggedIn}
|
||||
<a
|
||||
href="/login"
|
||||
title={m.book_detail_signin_to_save()}
|
||||
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if data.inLib}
|
||||
<button
|
||||
onclick={toggleSave}
|
||||
disabled={saving}
|
||||
title={saved ? 'Remove from library' : 'Add to library'}
|
||||
title={saved ? m.book_detail_remove_from_library() : m.book_detail_add_to_library()}
|
||||
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border transition-colors disabled:opacity-50
|
||||
{saved
|
||||
? 'bg-amber-400/20 text-amber-300 border-amber-400/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
|
||||
: 'bg-zinc-700 text-zinc-400 border-zinc-600 hover:bg-zinc-600 hover:text-zinc-100'}"
|
||||
? 'bg-(--color-brand)/20 text-(--color-brand-dim) border-(--color-brand)/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
|
||||
: 'bg-(--color-surface-3) text-(--color-muted) border-(--color-border) hover:bg-(--color-surface-3) hover:text-(--color-text)'}"
|
||||
>
|
||||
{#if saving}
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
@@ -329,28 +345,27 @@
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════ Chapters row ══ -->
|
||||
<div class="flex flex-col divide-y divide-zinc-800 border border-zinc-800 rounded-xl overflow-hidden mb-6">
|
||||
<!-- Chapters row: links to the full chapter list page -->
|
||||
<div class="flex flex-col divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden mb-6">
|
||||
<a
|
||||
href="/books/{book.slug}/chapters"
|
||||
class="flex items-center gap-3 px-4 py-3.5 hover:bg-zinc-800/60 transition-colors group"
|
||||
class="flex items-center gap-3 px-4 py-3.5 hover:bg-(--color-surface-2)/60 transition-colors group"
|
||||
>
|
||||
<svg class="w-4 h-4 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<svg class="w-4 h-4 text-(--color-brand) flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h10"/>
|
||||
</svg>
|
||||
<div class="flex flex-col min-w-0 flex-1">
|
||||
<span class="text-sm font-semibold text-zinc-200">Chapters</span>
|
||||
<span class="text-sm font-semibold text-(--color-text)">{m.chapters_heading()}</span>
|
||||
{#if chapterList.length > 0}
|
||||
<span class="text-xs text-zinc-500">
|
||||
<span class="text-xs text-(--color-muted)">
|
||||
{#if data.lastChapter && data.lastChapter > 0}
|
||||
Reading ch.{data.lastChapter} of {chapterList.length}
|
||||
{m.book_detail_reading_ch({ n: String(data.lastChapter), total: String(chapterList.length) })}
|
||||
{:else}
|
||||
{chapterList.length} chapter{chapterList.length === 1 ? '' : 's'}
|
||||
{m.book_detail_n_chapters({ n: String(chapterList.length) })}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-zinc-600 group-hover:text-zinc-400 transition-colors flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<svg class="w-4 h-4 text-(--color-muted) group-hover:text-(--color-muted) transition-colors flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
@@ -360,44 +375,44 @@
|
||||
<div>
|
||||
<button
|
||||
onclick={() => (adminOpen = !adminOpen)}
|
||||
class="w-full flex items-center gap-2 px-4 py-2.5 text-xs font-medium text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/50 transition-colors text-left"
|
||||
class="w-full flex items-center gap-2 px-4 py-2.5 text-xs font-medium text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)/50 transition-colors text-left"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
Admin
|
||||
{m.book_detail_admin()}
|
||||
<svg class="w-3 h-3 ml-auto transition-transform {adminOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if adminOpen}
|
||||
<div class="px-4 py-3 border-t border-zinc-800 flex flex-col gap-4">
|
||||
<div class="px-4 py-3 border-t border-(--color-border) flex flex-col gap-4">
|
||||
<!-- Rescrape -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<button
|
||||
onclick={rescrape}
|
||||
disabled={scraping}
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{scraping ? 'bg-zinc-700 text-zinc-500 cursor-not-allowed' : 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600'}"
|
||||
{scraping ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'}"
|
||||
>
|
||||
{#if scraping}
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Queuing…
|
||||
{m.book_detail_rescraping()}
|
||||
{:else}
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
Rescrape book
|
||||
{m.book_detail_rescrape_book()}
|
||||
{/if}
|
||||
</button>
|
||||
{#if scrapeResult}
|
||||
<span class="text-xs {scrapeResult === 'queued' ? 'text-green-400' : scrapeResult === 'busy' ? 'text-amber-400' : 'text-red-400'}">
|
||||
{scrapeResult === 'queued' ? 'Queued.' : scrapeResult === 'busy' ? 'Scraper busy.' : 'Error.'}
|
||||
<span class="text-xs {scrapeResult === 'queued' ? 'text-green-400' : scrapeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
|
||||
{scrapeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : scrapeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -405,25 +420,25 @@
|
||||
<!-- Range scrape -->
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="range-from" class="text-xs text-zinc-500">From chapter</label>
|
||||
<label for="range-from" class="text-xs text-(--color-muted)">{m.book_detail_from_chapter()}</label>
|
||||
<input
|
||||
id="range-from"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={rangeFrom}
|
||||
placeholder="1"
|
||||
class="w-24 px-2 py-1 rounded bg-zinc-700 border border-zinc-600 text-zinc-200 text-xs focus:outline-none focus:border-amber-400"
|
||||
class="w-24 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="range-to" class="text-xs text-zinc-500">To chapter (optional)</label>
|
||||
<label for="range-to" class="text-xs text-(--color-muted)">{m.book_detail_to_chapter()}</label>
|
||||
<input
|
||||
id="range-to"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={rangeTo}
|
||||
placeholder="end"
|
||||
class="w-24 px-2 py-1 rounded bg-zinc-700 border border-zinc-600 text-zinc-200 text-xs focus:outline-none focus:border-amber-400"
|
||||
class="w-24 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -431,14 +446,14 @@
|
||||
disabled={rangeScraping || !rangeFrom}
|
||||
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||||
{rangeScraping || !rangeFrom
|
||||
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
|
||||
: 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/40 border border-amber-500/30'}"
|
||||
? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed'
|
||||
: 'bg-(--color-brand)/20 text-(--color-brand-dim) hover:bg-(--color-brand)/40 border border-(--color-brand)/30'}"
|
||||
>
|
||||
{rangeScraping ? 'Queuing…' : 'Scrape range'}
|
||||
{rangeScraping ? m.book_detail_range_queuing() : m.book_detail_scrape_range()}
|
||||
</button>
|
||||
{#if rangeResult}
|
||||
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-amber-400' : 'text-red-400'}">
|
||||
{rangeResult === 'queued' ? 'Range scrape queued.' : rangeResult === 'busy' ? 'Scraper busy.' : 'Error queuing.'}
|
||||
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
|
||||
{rangeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : rangeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import type { ChapterIdx } from '$lib/server/pocketbase';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -57,39 +58,39 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.book.title} — Chapters — libnovel</title>
|
||||
<title>{m.chapters_page_title({ title: data.book.title })}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- ── Back link + title ─────────────────────────────────────────────────── -->
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<a
|
||||
href="/books/{data.book.slug}"
|
||||
class="flex items-center gap-1.5 text-zinc-400 hover:text-zinc-200 transition-colors text-sm"
|
||||
class="flex items-center gap-1.5 text-(--color-muted) hover:text-(--color-text) transition-colors text-sm"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back
|
||||
{m.common_back()}
|
||||
</a>
|
||||
<span class="text-zinc-700">/</span>
|
||||
<h1 class="text-base font-semibold text-zinc-200 truncate">{data.book.title}</h1>
|
||||
<span class="text-(--color-border)">/</span>
|
||||
<h1 class="text-base font-semibold text-(--color-text) truncate">{data.book.title}</h1>
|
||||
</div>
|
||||
|
||||
<!-- ── Search bar ───────────────────────────────────────────────────────── -->
|
||||
<div class="relative mb-4">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted) pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8"/><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35"/>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
placeholder={m.chapters_search_placeholder()}
|
||||
bind:value={searchQuery}
|
||||
class="w-full pl-9 pr-4 py-2.5 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-200 placeholder-zinc-500 text-sm focus:outline-none focus:border-amber-400 transition-colors"
|
||||
class="w-full pl-9 pr-4 py-2.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-text) placeholder-zinc-500 text-sm focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
onclick={() => (searchQuery = '')}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-(--color-muted) hover:text-(--color-text)"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
@@ -107,9 +108,9 @@
|
||||
onclick={() => (activeGroup = i)}
|
||||
class="px-2.5 py-1 rounded text-xs font-medium transition-colors
|
||||
{activeGroup === i
|
||||
? 'bg-amber-400 text-zinc-900'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'}
|
||||
{currentGroup === i && activeGroup !== i ? 'ring-1 ring-amber-400/50' : ''}"
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'bg-(--color-surface-2) text-(--color-muted) hover:bg-(--color-surface-3) hover:text-(--color-text)'}
|
||||
{currentGroup === i && activeGroup !== i ? 'ring-1 ring-(--color-brand)/50' : ''}"
|
||||
>
|
||||
{groupLabel(i)}
|
||||
</button>
|
||||
@@ -121,26 +122,26 @@
|
||||
{#if data.lastChapter && data.lastChapter > 0 && !searchQuery && activeGroup !== currentGroup}
|
||||
<button
|
||||
onclick={() => (activeGroup = currentGroup)}
|
||||
class="flex items-center gap-2 w-full px-3 py-2 mb-3 rounded-lg bg-amber-400/10 border border-amber-400/25 text-amber-400 text-sm hover:bg-amber-400/20 transition-colors"
|
||||
class="flex items-center gap-2 w-full px-3 py-2 mb-3 rounded-lg bg-(--color-brand)/10 border border-(--color-brand)/25 text-(--color-brand) text-sm hover:bg-(--color-brand)/20 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
Jump to Ch.{data.lastChapter}
|
||||
{m.chapters_jump_to({ n: String(data.lastChapter) })}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
|
||||
{#if visibleChapters.length === 0}
|
||||
{#if searchQuery}
|
||||
<p class="text-zinc-500 text-sm py-8 text-center">No chapters match "{searchQuery}"</p>
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">{m.chapters_no_match({ q: searchQuery })}</p>
|
||||
{:else}
|
||||
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
|
||||
<p class="text-(--color-muted) text-sm">{m.chapters_none_available()}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Result count while searching -->
|
||||
{#if searchQuery}
|
||||
<p class="text-xs text-zinc-500 mb-2">{visibleChapters.length} result{visibleChapters.length === 1 ? '' : 's'}</p>
|
||||
<p class="text-xs text-(--color-muted) mb-2">{m.chapters_result_count({ n: String(visibleChapters.length) })}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
@@ -150,12 +151,12 @@
|
||||
href="/books/{data.book.slug}/chapters/{chapter.number}"
|
||||
id="ch-{chapter.number}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded transition-colors group
|
||||
{isCurrent ? 'bg-zinc-800' : 'hover:bg-zinc-800/60'}"
|
||||
{isCurrent ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)/60'}"
|
||||
>
|
||||
<!-- Number badge -->
|
||||
<span
|
||||
class="w-9 text-right text-sm font-mono flex-shrink-0
|
||||
{isCurrent ? 'text-amber-400 font-semibold' : 'text-zinc-600'}"
|
||||
{isCurrent ? 'text-(--color-brand) font-semibold' : 'text-(--color-muted)'}"
|
||||
>
|
||||
{chapter.number}
|
||||
</span>
|
||||
@@ -163,21 +164,21 @@
|
||||
<!-- Title -->
|
||||
<span
|
||||
class="flex-1 min-w-0 text-sm truncate transition-colors
|
||||
{isCurrent ? 'text-amber-300 font-medium' : 'text-zinc-300 group-hover:text-zinc-100'}"
|
||||
{isCurrent ? 'text-(--color-brand-dim) font-medium' : 'text-(--color-text) group-hover:text-(--color-text)'}"
|
||||
>
|
||||
{chapter.title || `Chapter ${chapter.number}`}
|
||||
{chapter.title || m.reader_chapter_n({ n: String(chapter.number) })}
|
||||
</span>
|
||||
|
||||
<!-- Date — desktop only -->
|
||||
{#if chapter.date_label}
|
||||
<span class="hidden sm:block text-xs text-zinc-600 flex-shrink-0">
|
||||
<span class="hidden sm:block text-xs text-(--color-muted) flex-shrink-0">
|
||||
{chapter.date_label}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Reading indicator -->
|
||||
{#if isCurrent}
|
||||
<span class="text-xs text-amber-500 font-medium flex-shrink-0">reading</span>
|
||||
<span class="text-xs text-(--color-brand) font-medium flex-shrink-0">{m.chapters_reading_indicator()}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
@@ -185,15 +186,15 @@
|
||||
|
||||
<!-- Bottom page-group nav (mirrors top, for long lists) -->
|
||||
{#if !searchQuery && totalGroups > 1}
|
||||
<div class="flex flex-wrap gap-1.5 mt-5 pt-4 border-t border-zinc-800">
|
||||
<div class="flex flex-wrap gap-1.5 mt-5 pt-4 border-t border-(--color-border)">
|
||||
{#each Array(totalGroups) as _, i}
|
||||
<button
|
||||
onclick={() => { activeGroup = i; window.scrollTo({ top: 0, behavior: 'smooth' }); }}
|
||||
class="px-2.5 py-1 rounded text-xs font-medium transition-colors
|
||||
{activeGroup === i
|
||||
? 'bg-amber-400 text-zinc-900'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'}
|
||||
{currentGroup === i && activeGroup !== i ? 'ring-1 ring-amber-400/50' : ''}"
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'bg-(--color-surface-2) text-(--color-muted) hover:bg-(--color-surface-3) hover:text-(--color-text)'}
|
||||
{currentGroup === i && activeGroup !== i ? 'ring-1 ring-(--color-brand)/50' : ''}"
|
||||
>
|
||||
{groupLabel(i)}
|
||||
</button>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import type { Voice } from '$lib/types';
|
||||
|
||||
const SUPPORTED_LANGS = new Set(['ru', 'id', 'pt', 'fr']);
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
const { slug } = params;
|
||||
const n = parseInt(params.n, 10);
|
||||
@@ -15,6 +17,10 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
const isPreview = url.searchParams.get('preview') === '1';
|
||||
const chapterUrl = url.searchParams.get('chapter_url') ?? '';
|
||||
const chapterTitle = url.searchParams.get('title') ?? '';
|
||||
const rawLang = url.searchParams.get('lang') ?? '';
|
||||
// Non-pro users can only read EN — silently ignore lang param
|
||||
const lang = locals.isPro && SUPPORTED_LANGS.has(rawLang) ? rawLang : '';
|
||||
const useTranslation = lang !== '';
|
||||
|
||||
if (isPreview) {
|
||||
// ── Preview path: scrape chapter live, nothing from PocketBase/MinIO ──
|
||||
@@ -77,7 +83,10 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
next: null as number | null,
|
||||
chapters: [] as { number: number; title: string }[],
|
||||
sessionId: locals.sessionId,
|
||||
isPreview: true
|
||||
isPreview: true,
|
||||
lang: '',
|
||||
translationStatus: 'unavailable' as string,
|
||||
isPro: locals.isPro
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,7 +114,38 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
// Non-critical — UI will use store default
|
||||
}
|
||||
|
||||
// Fetch chapter markdown directly from the backend (server-side MinIO read)
|
||||
// ── Translation path: try to serve translated HTML ─────────────────────
|
||||
if (useTranslation) {
|
||||
try {
|
||||
const tRes = await backendFetch(
|
||||
`/api/translation/${encodeURIComponent(slug)}/${n}?lang=${lang}`
|
||||
);
|
||||
if (tRes.ok) {
|
||||
const tData = (await tRes.json()) as { html: string; lang: string };
|
||||
const prevChapter = chapters.find((c) => c.number === n - 1) ?? null;
|
||||
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
|
||||
return {
|
||||
book: { slug: book.slug, title: book.title, cover: book.cover ?? '' },
|
||||
chapter: chapterIdx,
|
||||
html: tData.html,
|
||||
voices,
|
||||
prev: prevChapter ? prevChapter.number : null,
|
||||
next: nextChapter ? nextChapter.number : null,
|
||||
chapters: chapters.map((c) => ({ number: c.number, title: c.title })),
|
||||
sessionId: locals.sessionId,
|
||||
isPreview: false,
|
||||
lang,
|
||||
translationStatus: 'done',
|
||||
isPro: locals.isPro
|
||||
};
|
||||
}
|
||||
// 404 = not generated yet — fall through to original, UI can trigger generation
|
||||
} catch {
|
||||
// Non-critical — fall through to original content
|
||||
}
|
||||
}
|
||||
|
||||
// ── Original content path ──────────────────────────────────────────────
|
||||
let html = '';
|
||||
try {
|
||||
const res = await backendFetch(`/api/chapter-markdown/${encodeURIComponent(slug)}/${n}`);
|
||||
@@ -122,6 +162,22 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
error(502, 'Could not fetch chapter content');
|
||||
}
|
||||
|
||||
// Check translation status for the UI switcher (non-blocking)
|
||||
let translationStatus = 'idle';
|
||||
if (useTranslation) {
|
||||
try {
|
||||
const stRes = await backendFetch(
|
||||
`/api/translation/status/${encodeURIComponent(slug)}/${n}?lang=${lang}`
|
||||
);
|
||||
if (stRes.ok) {
|
||||
const stData = (await stRes.json()) as { status: string };
|
||||
translationStatus = stData.status ?? 'idle';
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
const prevChapter = chapters.find((c) => c.number === n - 1) ?? null;
|
||||
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
|
||||
|
||||
@@ -134,6 +190,9 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
next: nextChapter ? nextChapter.number : null,
|
||||
chapters: chapters.map((c) => ({ number: c.number, title: c.title })),
|
||||
sessionId: locals.sessionId,
|
||||
isPreview: false
|
||||
isPreview: false,
|
||||
lang: useTranslation ? lang : '',
|
||||
translationStatus,
|
||||
isPro: locals.isPro
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,24 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let html = $state(untrack(() => data.html));
|
||||
let fetchingContent = $state(untrack(() => !data.isPreview && !data.html));
|
||||
let fetchError = $state('');
|
||||
let audioProRequired = $state(false);
|
||||
|
||||
// ── Word count ────────────────────────────────────────────────────────────
|
||||
function countWords(htmlStr: string | null): number {
|
||||
if (!htmlStr) return 0;
|
||||
// Strip HTML tags, collapse whitespace, split on whitespace
|
||||
return htmlStr.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).filter(Boolean).length;
|
||||
// Translation state
|
||||
const SUPPORTED_LANGS = [
|
||||
{ code: 'ru', label: 'RU' },
|
||||
{ code: 'id', label: 'ID' },
|
||||
{ code: 'pt', label: 'PT' },
|
||||
{ code: 'fr', label: 'FR' }
|
||||
];
|
||||
let translationStatus = $state(untrack(() => data.translationStatus ?? 'idle'));
|
||||
let translatingLang = $state(untrack(() => data.lang ?? ''));
|
||||
let pollingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function currentLang() {
|
||||
return page.url.searchParams.get('lang') ?? '';
|
||||
}
|
||||
|
||||
const wordCount = $derived(countWords(html));
|
||||
function langUrl(lang: string) {
|
||||
const u = new URL(page.url);
|
||||
if (lang) u.searchParams.set('lang', lang);
|
||||
else u.searchParams.delete('lang');
|
||||
return u.pathname + u.search;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
async function requestTranslation(lang: string) {
|
||||
if (!data.isPro) {
|
||||
// Don't even attempt — show upgrade inline
|
||||
return;
|
||||
}
|
||||
translatingLang = lang;
|
||||
translationStatus = 'pending';
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/translation/${encodeURIComponent(data.book.slug)}/${data.chapter.number}?lang=${lang}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (res.status === 402) {
|
||||
translationStatus = 'idle';
|
||||
translatingLang = '';
|
||||
return;
|
||||
}
|
||||
const d = (await res.json()) as { status: string };
|
||||
translationStatus = d.status ?? 'pending';
|
||||
if (d.status === 'done') {
|
||||
goto(langUrl(lang));
|
||||
} else {
|
||||
startPolling(lang);
|
||||
}
|
||||
} catch {
|
||||
translationStatus = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(lang: string) {
|
||||
if (pollingTimer) clearTimeout(pollingTimer);
|
||||
pollingTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/translation/status/${encodeURIComponent(data.book.slug)}/${data.chapter.number}?lang=${lang}`
|
||||
);
|
||||
const d = (await res.json()) as { status: string };
|
||||
translationStatus = d.status ?? 'idle';
|
||||
if (d.status === 'done') {
|
||||
goto(langUrl(lang));
|
||||
} else if (d.status === 'pending' || d.status === 'running') {
|
||||
startPolling(lang);
|
||||
}
|
||||
} catch {
|
||||
startPolling(lang); // retry on network error
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Umami analytics: track chapter reads
|
||||
window.umami?.track('chapter_read', {
|
||||
slug: data.book.slug,
|
||||
@@ -28,7 +94,7 @@
|
||||
// Record reading progress (skip for preview chapters)
|
||||
if (!data.isPreview) {
|
||||
try {
|
||||
await fetch('/api/progress', {
|
||||
fetch('/api/progress', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: data.book.slug, chapter: data.chapter.number })
|
||||
@@ -38,105 +104,211 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Resume polling if translation was in-progress at load time
|
||||
if (translatingLang && (translationStatus === 'pending' || translationStatus === 'running')) {
|
||||
startPolling(translatingLang);
|
||||
}
|
||||
|
||||
// If the normal path returned no content, fall back to live preview scrape
|
||||
if (!data.isPreview && !data.html) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/chapter-text-preview/${encodeURIComponent(data.book.slug)}/${data.chapter.number}`
|
||||
);
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const d = (await res.json()) as { text?: string };
|
||||
if (d.text) {
|
||||
const { marked } = await import('marked');
|
||||
html = await marked(d.text, { async: true });
|
||||
} else {
|
||||
fetchError = 'Chapter content not available.';
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/chapter-text-preview/${encodeURIComponent(data.book.slug)}/${data.chapter.number}`
|
||||
);
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const d = (await res.json()) as { text?: string };
|
||||
if (d.text) {
|
||||
const { marked } = await import('marked');
|
||||
html = await marked(d.text, { async: true });
|
||||
} else {
|
||||
fetchError = m.reader_audio_error();
|
||||
}
|
||||
} catch {
|
||||
fetchError = m.reader_fetching_chapter();
|
||||
} finally {
|
||||
fetchingContent = false;
|
||||
}
|
||||
} catch (e) {
|
||||
fetchError = 'Could not fetch chapter content.';
|
||||
} finally {
|
||||
fetchingContent = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingTimer) clearTimeout(pollingTimer);
|
||||
};
|
||||
});
|
||||
|
||||
const wordCount = $derived(
|
||||
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.chapter.title || `Chapter ${data.chapter.number}`} — {data.book.title} — libnovel</title>
|
||||
<title>{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })} — {data.book.title} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Top nav -->
|
||||
<div class="flex items-center justify-between mb-6 gap-4">
|
||||
<a
|
||||
href="/books/{data.book.slug}"
|
||||
class="text-zinc-400 hover:text-zinc-100 text-sm flex items-center gap-1 transition-colors"
|
||||
href="/books/{data.book.slug}/chapters"
|
||||
class="text-(--color-muted) hover:text-(--color-text) text-sm flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Chapters
|
||||
{m.reader_back_to_chapters()}
|
||||
</a>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{#if data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="px-3 py-1.5 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors"
|
||||
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
← Ch.{data.prev}
|
||||
← {m.reader_chapter_n({ n: String(data.prev) })}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="px-3 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
|
||||
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
Ch.{data.next} →
|
||||
{m.reader_chapter_n({ n: String(data.next) })} →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter heading -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-xl font-bold text-zinc-100">
|
||||
{data.chapter.title || `Chapter ${data.chapter.number}`}
|
||||
<div class="mb-4">
|
||||
<h1 class="text-xl font-bold text-(--color-text)">
|
||||
{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
</h1>
|
||||
{#if wordCount > 0}
|
||||
<p class="text-zinc-600 text-xs mt-1">{wordCount.toLocaleString()} words</p>
|
||||
<p class="text-(--color-muted) text-xs mt-1">
|
||||
{m.reader_words({ n: wordCount.toLocaleString() })}
|
||||
<span class="opacity-50 mx-1">·</span>
|
||||
~{Math.max(1, Math.round(wordCount / 200))} min read
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Language switcher (not shown for preview chapters) -->
|
||||
{#if !data.isPreview}
|
||||
<div class="flex items-center gap-2 mb-6 flex-wrap">
|
||||
<span class="text-(--color-muted) text-xs">Read in:</span>
|
||||
|
||||
<!-- English (original) -->
|
||||
<a
|
||||
href={langUrl('')}
|
||||
class="px-2 py-0.5 rounded text-xs font-medium transition-colors {currentLang() === '' ? 'bg-(--color-brand) text-(--color-surface)' : 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
EN
|
||||
</a>
|
||||
|
||||
{#each SUPPORTED_LANGS as { code, label }}
|
||||
{#if !data.isPro}
|
||||
<!-- Locked for free users -->
|
||||
<a
|
||||
href="/profile"
|
||||
title="Upgrade to Pro to read in {label}"
|
||||
class="flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) opacity-60 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{label}
|
||||
</a>
|
||||
{:else if currentLang() === code && (translationStatus === 'pending' || translationStatus === 'running')}
|
||||
<!-- Spinning indicator while translating -->
|
||||
<span class="flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted)">
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{label}
|
||||
</span>
|
||||
{:else if currentLang() === code}
|
||||
<!-- Active translated lang -->
|
||||
<a
|
||||
href={langUrl(code)}
|
||||
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-brand) text-(--color-surface)"
|
||||
>{label}</a>
|
||||
{:else}
|
||||
<!-- Inactive lang: click to request/navigate -->
|
||||
<button
|
||||
onclick={() => requestTranslation(code)}
|
||||
disabled={translatingLang !== '' && translatingLang !== code && (translationStatus === 'pending' || translationStatus === 'running')}
|
||||
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>{label}</button>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if !data.isPro}
|
||||
<a href="/profile" class="text-xs text-(--color-brand) hover:underline ml-1">Upgrade to Pro</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Audio player -->
|
||||
{#if !data.isPreview}
|
||||
{#if !page.data.user}
|
||||
<!-- Unauthenticated: sign-in prompt -->
|
||||
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-border) flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-(--color-text) text-sm font-medium">{m.reader_signin_for_audio()}</p>
|
||||
<p class="text-(--color-muted) text-xs mt-0.5">{m.reader_signin_audio_desc()}</p>
|
||||
</div>
|
||||
<a
|
||||
href="/login"
|
||||
class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
{m.nav_sign_in()}
|
||||
</a>
|
||||
</div>
|
||||
{:else if audioProRequired}
|
||||
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-brand)/30 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-(--color-text) text-sm font-medium">Daily audio limit reached</p>
|
||||
<p class="text-(--color-muted) text-xs mt-0.5">Free users can listen to 3 chapters per day. Upgrade to Pro for unlimited audio.</p>
|
||||
</div>
|
||||
<a
|
||||
href="/profile"
|
||||
class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Upgrade
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<AudioPlayer
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
chapterTitle={data.chapter.title || `Chapter ${data.chapter.number}`}
|
||||
chapterTitle={data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
bookTitle={data.book.title}
|
||||
cover={data.book.cover}
|
||||
nextChapter={data.next}
|
||||
chapters={data.chapters}
|
||||
voices={data.voices}
|
||||
onProRequired={() => { audioProRequired = true; }}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="mb-6 px-4 py-3 rounded bg-zinc-800/60 border border-zinc-700 text-zinc-500 text-sm">
|
||||
Preview chapter — audio not available for books outside the library.
|
||||
<div class="mb-6 px-4 py-3 rounded bg-(--color-surface-2)/60 border border-(--color-border) text-(--color-muted) text-sm">
|
||||
{m.reader_preview_audio_notice()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Chapter content -->
|
||||
{#if fetchingContent}
|
||||
<div class="flex flex-col items-center gap-3 py-16 text-zinc-500 text-sm">
|
||||
<div class="flex flex-col items-center gap-3 py-16 text-(--color-muted) text-sm">
|
||||
<svg class="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
Fetching chapter…
|
||||
{m.reader_fetching_chapter()}
|
||||
</div>
|
||||
{:else if !html}
|
||||
<div class="text-zinc-500 text-center py-16">
|
||||
<p>{fetchError || 'Chapter content not available.'}</p>
|
||||
<div class="text-(--color-muted) text-center py-16">
|
||||
<p>{fetchError || m.reader_audio_error()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="prose-chapter mt-8">
|
||||
@@ -145,23 +317,23 @@
|
||||
{/if}
|
||||
|
||||
<!-- Bottom nav -->
|
||||
<div class="flex justify-between mt-12 pt-6 border-t border-zinc-800 gap-4">
|
||||
<div class="flex justify-between mt-12 pt-6 border-t border-(--color-border) gap-4">
|
||||
{#if data.prev}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="px-4 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors"
|
||||
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
← Previous chapter
|
||||
← {m.reader_prev_chapter()}
|
||||
</a>
|
||||
{:else}
|
||||
<div></div>
|
||||
<span></span>
|
||||
{/if}
|
||||
{#if data.next}
|
||||
<a
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="px-4 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
|
||||
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
|
||||
>
|
||||
Next chapter →
|
||||
{m.reader_next_chapter()} →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { goto } from '$app/navigation';
|
||||
import { navigating } from '$app/state';
|
||||
import { untrack } from 'svelte';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import type { NovelListing } from './+page.server';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
// Track which novel card is currently being navigated to
|
||||
// ── Local filter state (mirrors URL params) ──────────────────────────────
|
||||
let filterSort = $state(untrack(() => data.sort));
|
||||
let filterGenre = $state(untrack(() => data.genre));
|
||||
let filterStatus = $state(untrack(() => data.status));
|
||||
|
||||
$effect(() => {
|
||||
filterSort = data.sort;
|
||||
filterGenre = data.genre;
|
||||
filterStatus = data.status;
|
||||
});
|
||||
|
||||
function applyFilters() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('sort', filterSort);
|
||||
params.set('genre', filterGenre);
|
||||
params.set('status', filterStatus);
|
||||
params.set('page', '1');
|
||||
goto(`/catalogue?${params.toString()}`);
|
||||
}
|
||||
|
||||
let loadingSlug = $state<string | null>(null);
|
||||
|
||||
// Clear loading state when navigation ends (success or failure)
|
||||
$effect(() => {
|
||||
if (!navigating) loadingSlug = null;
|
||||
});
|
||||
@@ -20,15 +40,11 @@
|
||||
}
|
||||
|
||||
// ── Infinite scroll state ────────────────────────────────────────────────
|
||||
// novels is the accumulated list across all fetched pages.
|
||||
// Seeded from SSR page 1; new pages are appended client-side.
|
||||
let novels = $state<NovelListing[]>(untrack(() => data.novels));
|
||||
let currentPage = $state(untrack(() => data.page));
|
||||
let hasNext = $state(untrack(() => data.hasNext));
|
||||
let loadingMore = $state(false);
|
||||
|
||||
// A key derived from the active filters — when it changes, reset the list
|
||||
// to the fresh SSR data (SvelteKit already re-ran the server load).
|
||||
let filterKey = $derived(`${data.sort}|${data.genre}|${data.status}|${data.searchQuery}`);
|
||||
let lastFilterKey = '';
|
||||
$effect(() => {
|
||||
@@ -42,7 +58,6 @@
|
||||
|
||||
async function loadNextPage() {
|
||||
if (loadingMore || !hasNext) return;
|
||||
// Infinite scroll only applies in browse mode (not rank, not search)
|
||||
if (data.sort === 'rank' || data.searchQuery) return;
|
||||
|
||||
loadingMore = true;
|
||||
@@ -82,44 +97,42 @@
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
// Filter options — built from Meilisearch facet distribution when available,
|
||||
// with a hardcoded fallback list for when Meilisearch is not yet populated.
|
||||
const FALLBACK_GENRES = [
|
||||
'action', 'adventure', 'comedy', 'drama', 'fantasy', 'harem',
|
||||
'historical', 'horror', 'isekai', 'martial-arts', 'mystery',
|
||||
'psychological', 'romance', 'sci-fi', 'system', 'xianxia'
|
||||
];
|
||||
const genres = $derived([
|
||||
{ value: 'all', label: 'All Genres' },
|
||||
{ value: 'all', label: m.catalogue_genre_all() },
|
||||
...((data.genres?.length ? data.genres : FALLBACK_GENRES).map((g: string) => ({
|
||||
value: g,
|
||||
label: g.charAt(0).toUpperCase() + g.slice(1).replace(/-/g, ' ')
|
||||
})))
|
||||
]);
|
||||
const sorts = [
|
||||
{ value: 'popular', label: 'Popular' },
|
||||
{ value: 'new', label: 'New' },
|
||||
{ value: 'update', label: 'Updated' },
|
||||
{ value: 'top-rated', label: 'Top Rated' },
|
||||
{ value: 'rank', label: 'Ranking' }
|
||||
];
|
||||
const sorts = $derived([
|
||||
{ value: 'popular', label: m.catalogue_sort_popular() },
|
||||
{ value: 'new', label: m.catalogue_sort_new() },
|
||||
{ value: 'update', label: m.catalogue_sort_updated() },
|
||||
{ value: 'top-rated', label: m.catalogue_sort_top_rated() },
|
||||
{ value: 'rank', label: m.catalogue_sort_rank() }
|
||||
]);
|
||||
const FALLBACK_STATUSES = ['ongoing', 'completed'];
|
||||
const STATUS_LABELS: Record<string, () => string> = {
|
||||
ongoing: () => m.catalogue_status_ongoing(),
|
||||
completed: () => m.catalogue_status_completed(),
|
||||
};
|
||||
const statuses = $derived([
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'all', label: m.catalogue_status_all() },
|
||||
...((data.statuses?.length ? data.statuses : FALLBACK_STATUSES).map((s: string) => ({
|
||||
value: s,
|
||||
label: s.charAt(0).toUpperCase() + s.slice(1)
|
||||
label: STATUS_LABELS[s]?.() ?? (s.charAt(0).toUpperCase() + s.slice(1))
|
||||
})))
|
||||
]);
|
||||
|
||||
// When sort=rank the ranking API is used — pagination + genre/status filters
|
||||
// don't apply to that endpoint.
|
||||
const isRankView = $derived(data.sort === 'rank');
|
||||
const isSearchView = $derived(!!data.searchQuery);
|
||||
|
||||
|
||||
// View toggle: 'grid' | 'list'. Persisted in localStorage.
|
||||
// Rank view always uses list; otherwise restore saved preference (default: grid).
|
||||
const VIEW_KEY = 'libnovel:browse:view';
|
||||
function savedView(): 'grid' | 'list' {
|
||||
if (data.sort === 'rank') return 'list';
|
||||
@@ -130,7 +143,6 @@
|
||||
return 'grid';
|
||||
}
|
||||
let view = $state<'grid' | 'list'>(savedView());
|
||||
// Keep view in sync when sort changes via filter form, and persist changes.
|
||||
$effect(() => {
|
||||
if (data.sort === 'rank' && view === 'grid') view = 'list';
|
||||
});
|
||||
@@ -170,7 +182,6 @@
|
||||
// ── Collapsible filters panel ────────────────────────────────────────────
|
||||
let filtersOpen = $state(false);
|
||||
|
||||
// Human-readable summary of active filters shown on the toggle button
|
||||
const filterSummary = $derived((() => {
|
||||
const parts: string[] = [];
|
||||
const sortLabel = sorts.find((s) => s.value === data.sort)?.label ?? data.sort;
|
||||
@@ -186,7 +197,6 @@
|
||||
return parts.join(' · ');
|
||||
})());
|
||||
|
||||
// Whether any non-default filter is active (used to show a dot indicator)
|
||||
const hasActiveFilters = $derived(
|
||||
(data.genre && data.genre !== 'all') ||
|
||||
(data.status && data.status !== 'all') ||
|
||||
@@ -205,26 +215,26 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Catalogue — libnovel</title>
|
||||
<title>{m.catalogue_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-4">
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Catalogue</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">{m.catalogue_heading()}</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
{#if isSearchView}
|
||||
{novels.length} result{novels.length !== 1 ? 's' : ''} for "<span class="text-zinc-200">{data.searchQuery}</span>"
|
||||
{m.catalogue_search_results({ n: String(novels.length), s: novels.length !== 1 ? 's' : '', q: data.searchQuery })}
|
||||
{#if data.searchLocalCount > 0 || data.searchRemoteCount > 0}
|
||||
<span class="text-zinc-500 text-xs ml-1">({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire)</span>
|
||||
<span class="text-(--color-muted) text-xs ml-1">{m.catalogue_search_local_count({ local: String(data.searchLocalCount), remote: String(data.searchRemoteCount) })}</span>
|
||||
{/if}
|
||||
{:else if isRankView}
|
||||
{#if novels.length > 0}
|
||||
{novels.length} novels ranked from last catalogue scrape
|
||||
{m.catalogue_rank_ranked({ n: String(novels.length) })}
|
||||
{:else}
|
||||
No ranking data — run a full catalogue scrape to populate
|
||||
{m.catalogue_rank_no_data_body()}
|
||||
{/if}
|
||||
{:else}
|
||||
Browse novels from novelfire.net
|
||||
{m.catalogue_browse_source()}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -232,16 +242,16 @@
|
||||
<!-- Admin flash messages -->
|
||||
{#if form}
|
||||
{#if form.status === 'queued'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-emerald-900/40 border border-emerald-700 text-emerald-300 text-sm">
|
||||
Full catalogue scrape queued. Library and ranking will update as books are processed.
|
||||
<div class="mb-4 px-4 py-3 rounded bg-(--color-success)/10 border border-(--color-success)/40 text-(--color-success) text-sm">
|
||||
{m.catalogue_scrape_queued_flash()}
|
||||
</div>
|
||||
{:else if form.status === 'busy'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-yellow-900/40 border border-yellow-700 text-yellow-300 text-sm">
|
||||
A scrape job is already running. Check back once it finishes.
|
||||
<div class="mb-4 px-4 py-3 rounded bg-(--color-brand)/10 border border-(--color-brand)/40 text-(--color-brand) text-sm">
|
||||
{m.catalogue_scrape_busy_flash()}
|
||||
</div>
|
||||
{:else if form.status === 'error'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-red-900/40 border border-red-700 text-red-300 text-sm">
|
||||
Failed to queue scrape. Check that the scraper service is reachable.
|
||||
<div class="mb-4 px-4 py-3 rounded bg-(--color-danger)/10 border border-(--color-danger) text-(--color-danger) text-sm">
|
||||
{m.catalogue_scrape_error_flash()}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -254,21 +264,21 @@
|
||||
type="search"
|
||||
name="q"
|
||||
value={data.searchQuery}
|
||||
placeholder="Search…"
|
||||
class="flex-1 min-w-0 bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 placeholder-zinc-500"
|
||||
placeholder={m.catalogue_search_placeholder()}
|
||||
class="flex-1 min-w-0 bg-(--color-surface-2) border border-(--color-border) text-(--color-text) text-sm rounded px-3 py-2 focus:outline-none focus:border-(--color-brand) placeholder-zinc-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-3 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors whitespace-nowrap"
|
||||
class="px-3 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors whitespace-nowrap"
|
||||
>
|
||||
Search
|
||||
{m.catalogue_search_button()}
|
||||
</button>
|
||||
{#if data.searchQuery}
|
||||
<a
|
||||
href="/catalogue"
|
||||
class="px-3 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors whitespace-nowrap"
|
||||
class="px-3 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors whitespace-nowrap"
|
||||
>
|
||||
Clear
|
||||
{m.catalogue_clear_filters()}
|
||||
</a>
|
||||
{/if}
|
||||
</form>
|
||||
@@ -280,28 +290,27 @@
|
||||
aria-expanded={filtersOpen}
|
||||
class="relative flex items-center gap-1.5 px-3 py-2 rounded border text-sm font-medium transition-colors whitespace-nowrap
|
||||
{filtersOpen
|
||||
? 'bg-zinc-700 border-zinc-500 text-zinc-100'
|
||||
: 'bg-zinc-800 border-zinc-700 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100'}"
|
||||
? 'bg-(--color-surface-3) border-zinc-500 text-(--color-text)'
|
||||
: 'bg-(--color-surface-2) border-(--color-border) text-(--color-text) hover:border-zinc-500 hover:text-(--color-text)'}"
|
||||
>
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 4h18M7 8h10M11 12h2M9 16h6" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Filters</span>
|
||||
<!-- Active indicator dot -->
|
||||
<span class="hidden sm:inline">{m.catalogue_filters_label()}</span>
|
||||
{#if hasActiveFilters}
|
||||
<span class="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-amber-400"></span>
|
||||
<span class="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-(--color-brand)"></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- View toggle -->
|
||||
<div class="flex items-center bg-zinc-800 border border-zinc-700 rounded overflow-hidden shrink-0">
|
||||
<div class="flex items-center bg-(--color-surface-2) border border-(--color-border) rounded overflow-hidden shrink-0">
|
||||
<button
|
||||
onclick={() => (view = 'grid')}
|
||||
title="Grid view"
|
||||
title={m.catalogue_view_grid()}
|
||||
class="px-2.5 py-2 transition-colors {view === 'grid'
|
||||
? 'bg-zinc-600 text-zinc-100'
|
||||
: 'text-zinc-400 hover:text-zinc-200'}"
|
||||
? 'bg-(--color-surface-3) text-(--color-text)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
@@ -310,10 +319,10 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (view = 'list')}
|
||||
title="List view"
|
||||
title={m.catalogue_view_list()}
|
||||
class="px-2.5 py-2 transition-colors {view === 'list'
|
||||
? 'bg-zinc-600 text-zinc-100'
|
||||
: 'text-zinc-400 hover:text-zinc-200'}"
|
||||
? 'bg-(--color-surface-3) text-(--color-text)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
@@ -338,9 +347,9 @@
|
||||
<button
|
||||
type="submit"
|
||||
disabled={refreshing}
|
||||
class="hidden sm:block px-3 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
class="hidden sm:block px-3 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
{refreshing ? 'Queuing…' : 'Refresh'}
|
||||
{refreshing ? m.catalogue_refreshing() : m.catalogue_refresh()}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
@@ -348,15 +357,15 @@
|
||||
|
||||
<!-- Active filter summary (shown when panel is closed and filters are active) -->
|
||||
{#if !filtersOpen && hasActiveFilters}
|
||||
<p class="text-xs text-zinc-500 mb-3">
|
||||
<span class="text-zinc-400">{filterSummary}</span>
|
||||
<a href="/catalogue" class="ml-2 text-zinc-600 hover:text-zinc-400 underline underline-offset-2">clear</a>
|
||||
<p class="text-xs text-(--color-muted) mb-3">
|
||||
<span class="text-(--color-muted)">{filterSummary}</span>
|
||||
<a href="/catalogue" class="ml-2 text-(--color-muted) hover:text-(--color-muted) underline underline-offset-2">{m.catalogue_clear_filters().toLowerCase()}</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Collapsible filter panel -->
|
||||
{#if filtersOpen}
|
||||
<!-- Admin refresh (mobile only — outside filter form to avoid nested <form>) -->
|
||||
<!-- Admin refresh (mobile only) -->
|
||||
{#if data.isAdmin}
|
||||
<form
|
||||
method="POST"
|
||||
@@ -373,76 +382,76 @@
|
||||
<button
|
||||
type="submit"
|
||||
disabled={refreshing}
|
||||
class="w-full px-3 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="w-full px-3 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{refreshing ? 'Queuing…' : 'Refresh catalogue'}
|
||||
{refreshing ? m.catalogue_refreshing() : m.catalogue_refresh_mobile()}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<form method="GET" action="/catalogue" class="mb-4 p-3 rounded-lg bg-zinc-800/60 border border-zinc-700 flex flex-col gap-3">
|
||||
<form method="GET" action="/catalogue" class="mb-4 p-3 rounded-lg bg-(--color-surface-2)/60 border border-(--color-border) flex flex-col gap-3">
|
||||
<input type="hidden" name="page" value="1" />
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="filter-sort" class="text-xs text-zinc-500 uppercase tracking-wide">Sort</label>
|
||||
<label for="filter-sort" class="text-xs text-(--color-muted) uppercase tracking-wide">{m.catalogue_filter_sort()}</label>
|
||||
<select
|
||||
id="filter-sort"
|
||||
name="sort"
|
||||
value={data.sort}
|
||||
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 w-full"
|
||||
bind:value={filterSort}
|
||||
class="bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm rounded px-3 py-2 focus:outline-none focus:border-(--color-brand) w-full"
|
||||
>
|
||||
{#each sorts as s}
|
||||
<option value={s.value}>{s.label}</option>
|
||||
<option value={s.value} selected={s.value === filterSort}>{s.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="filter-genre" class="text-xs text-zinc-500 uppercase tracking-wide">Genre</label>
|
||||
<label for="filter-genre" class="text-xs text-(--color-muted) uppercase tracking-wide">{m.catalogue_filter_genre()}</label>
|
||||
<select
|
||||
id="filter-genre"
|
||||
name="genre"
|
||||
value={data.genre}
|
||||
bind:value={filterGenre}
|
||||
disabled={isRankView}
|
||||
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed w-full"
|
||||
class="bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm rounded px-3 py-2 focus:outline-none focus:border-(--color-brand) disabled:opacity-40 disabled:cursor-not-allowed w-full"
|
||||
>
|
||||
{#each genres as g}
|
||||
<option value={g.value}>{g.label}</option>
|
||||
<option value={g.value} selected={g.value === filterGenre}>{g.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="filter-status" class="text-xs text-zinc-500 uppercase tracking-wide">Status</label>
|
||||
<label for="filter-status" class="text-xs text-(--color-muted) uppercase tracking-wide">{m.catalogue_filter_status()}</label>
|
||||
<select
|
||||
id="filter-status"
|
||||
name="status"
|
||||
value={data.status}
|
||||
bind:value={filterStatus}
|
||||
disabled={isRankView}
|
||||
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed w-full"
|
||||
class="bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm rounded px-3 py-2 focus:outline-none focus:border-(--color-brand) disabled:opacity-40 disabled:cursor-not-allowed w-full"
|
||||
>
|
||||
{#each statuses as st}
|
||||
<option value={st.value}>{st.label}</option>
|
||||
<option value={st.value} selected={st.value === filterStatus}>{st.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isRankView}
|
||||
<p class="text-xs text-zinc-500 italic">Genre & status filters apply to Browse only</p>
|
||||
<p class="text-xs text-(--color-muted) italic">{m.catalogue_filter_rank_note()}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<a href="/catalogue" class="px-4 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors">
|
||||
Reset
|
||||
<a href="/catalogue" class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors">
|
||||
{m.catalogue_reset()}
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
onclick={() => (filtersOpen = false)}
|
||||
class="px-4 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
|
||||
type="button"
|
||||
onclick={applyFilters}
|
||||
class="px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Apply
|
||||
{m.catalogue_apply()}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -450,19 +459,19 @@
|
||||
|
||||
<!-- Content -->
|
||||
{#if novels.length === 0}
|
||||
<div class="text-center py-20 text-zinc-500">
|
||||
<p class="text-lg">{isSearchView ? 'No results found.' : isRankView ? 'No ranking data.' : 'No novels found.'}</p>
|
||||
<div class="text-center py-20 text-(--color-muted)">
|
||||
<p class="text-lg">{isSearchView ? m.catalogue_no_results_search() : isRankView ? m.catalogue_rank_no_data() : m.catalogue_no_results()}</p>
|
||||
<p class="text-sm mt-2">
|
||||
{#if isSearchView}
|
||||
Try a different search term.
|
||||
{m.catalogue_no_results_try()}
|
||||
{:else if isRankView}
|
||||
{#if data.isAdmin}
|
||||
Click <span class="text-amber-400">Refresh catalogue</span> above to trigger a full catalogue scrape.
|
||||
{m.catalogue_rank_run_scrape_admin()}
|
||||
{:else}
|
||||
Ask an admin to run a catalogue scrape.
|
||||
{m.catalogue_rank_run_scrape_user()}
|
||||
{/if}
|
||||
{:else}
|
||||
Try different filters or check back later.
|
||||
{m.catalogue_no_results_filters()}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -475,11 +484,11 @@
|
||||
<a
|
||||
href="/books/{novel.slug}"
|
||||
onclick={() => handleNovelClick(novel.slug)}
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 border transition-colors relative
|
||||
{isLoading ? 'border-amber-400/60' : 'border-zinc-700 hover:border-zinc-500'}"
|
||||
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) border transition-colors relative
|
||||
{isLoading ? 'border-(--color-brand)/60' : 'border-(--color-border) hover:border-zinc-500'}"
|
||||
>
|
||||
<!-- Cover -->
|
||||
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
|
||||
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden relative">
|
||||
{#if novel.cover}
|
||||
<img
|
||||
src={novel.cover}
|
||||
@@ -488,7 +497,7 @@
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<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" />
|
||||
@@ -496,19 +505,19 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if novel.rank}
|
||||
<span class="absolute top-1 left-1 text-xs px-1.5 py-0.5 rounded bg-zinc-900/80 text-amber-400 font-bold">
|
||||
<span class="absolute top-1 left-1 text-xs px-1.5 py-0.5 rounded bg-(--color-surface)/80 text-(--color-brand) font-bold">
|
||||
{novel.rank}
|
||||
</span>
|
||||
{/if}
|
||||
{#if novel.rating}
|
||||
<span class="absolute top-1 right-1 text-xs px-1.5 py-0.5 rounded bg-zinc-900/80 text-zinc-300">
|
||||
<span class="absolute top-1 right-1 text-xs px-1.5 py-0.5 rounded bg-(--color-surface)/80 text-(--color-text)">
|
||||
{novel.rating}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Loading overlay -->
|
||||
{#if isLoading}
|
||||
<div class="absolute inset-0 bg-zinc-900/70 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
|
||||
<div class="absolute inset-0 bg-(--color-surface)/70 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
@@ -518,31 +527,31 @@
|
||||
|
||||
<!-- Info -->
|
||||
<div class="p-2 flex flex-col gap-1 flex-1">
|
||||
<h2 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{novel.title}</h2>
|
||||
<h2 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{novel.title}</h2>
|
||||
{#if novel.author}
|
||||
<p class="text-xs text-zinc-500 truncate">{novel.author}</p>
|
||||
<p class="text-xs text-(--color-muted) truncate">{novel.author}</p>
|
||||
{:else if novel.chapters}
|
||||
<p class="text-xs text-zinc-500 truncate">{novel.chapters}</p>
|
||||
<p class="text-xs text-(--color-muted) truncate">{novel.chapters}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Admin: per-novel scrape button -->
|
||||
{#if data.isAdmin && novel.url}
|
||||
<div class="mt-auto pt-1">
|
||||
{#if scrapeResult[novel.slug] === 'queued'}
|
||||
<span class="text-xs text-emerald-400 font-medium">Queued</span>
|
||||
<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">Scraper busy</span>
|
||||
<span class="text-xs text-yellow-400 font-medium">{m.catalogue_scrape_busy_badge()}</span>
|
||||
{:else if scrapeResult[novel.slug] === 'forbidden'}
|
||||
<span class="text-xs text-red-400 font-medium">Forbidden</span>
|
||||
<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-red-400 font-medium">Error</span>
|
||||
<span class="text-xs text-(--color-danger) font-medium">{m.common_error()}</span>
|
||||
{:else}
|
||||
<button
|
||||
onclick={(e) => { e.preventDefault(); scrapeNovel(novel); }}
|
||||
disabled={scraping[novel.slug]}
|
||||
class="w-full text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-300 hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30"
|
||||
class="w-full text-xs px-2 py-1 rounded bg-amber-500/20 text-(--color-brand-dim) hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30"
|
||||
>
|
||||
{scraping[novel.slug] ? 'Scraping…' : 'Scrape'}
|
||||
{scraping[novel.slug] ? m.catalogue_scraping_novel() : m.catalogue_scrape_novel_button()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -558,20 +567,20 @@
|
||||
{#each novels as novel}
|
||||
{@const isLoading = loadingSlug === novel.slug}
|
||||
<div
|
||||
class="flex items-center gap-4 bg-zinc-800 border rounded-lg px-4 py-3 transition-colors
|
||||
{isLoading ? 'border-amber-400/60' : 'border-zinc-700 hover:border-zinc-500'}"
|
||||
class="flex items-center gap-4 bg-(--color-surface-2) border rounded-lg px-4 py-3 transition-colors
|
||||
{isLoading ? 'border-(--color-brand)/60' : 'border-(--color-border) hover:border-zinc-500'}"
|
||||
>
|
||||
<!-- Rank / index -->
|
||||
{#if novel.rank}
|
||||
<span class="text-amber-400 font-bold text-sm w-8 shrink-0 text-right">{novel.rank}</span>
|
||||
<span class="text-(--color-brand) font-bold text-sm w-8 shrink-0 text-right">{novel.rank}</span>
|
||||
{/if}
|
||||
|
||||
<!-- Cover thumbnail -->
|
||||
<div class="w-10 h-14 shrink-0 rounded overflow-hidden bg-zinc-900 relative">
|
||||
<div class="w-10 h-14 shrink-0 rounded overflow-hidden bg-(--color-surface) relative">
|
||||
{#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-zinc-600">
|
||||
<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" />
|
||||
@@ -579,8 +588,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if isLoading}
|
||||
<div class="absolute inset-0 bg-zinc-900/70 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
|
||||
<div class="absolute inset-0 bg-(--color-surface)/70 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
@@ -595,28 +604,28 @@
|
||||
href="/books/{novel.slug}"
|
||||
onclick={() => handleNovelClick(novel.slug)}
|
||||
class="text-sm font-semibold transition-colors line-clamp-1
|
||||
{isLoading ? 'text-amber-400' : 'text-zinc-100 hover:text-amber-400'}"
|
||||
{isLoading ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
|
||||
>
|
||||
{novel.title}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-sm font-semibold text-zinc-100 line-clamp-1">{novel.title}</span>
|
||||
<span class="text-sm font-semibold text-(--color-text) line-clamp-1">{novel.title}</span>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{#if novel.author}
|
||||
<span class="text-xs text-zinc-400">{novel.author}</span>
|
||||
<span class="text-xs text-(--color-muted)">{novel.author}</span>
|
||||
{/if}
|
||||
{#if novel.status}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300">{novel.status}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-text)">{novel.status}</span>
|
||||
{:else if novel.chapters}
|
||||
<span class="text-xs text-zinc-500">{novel.chapters}</span>
|
||||
<span class="text-xs text-(--color-muted)">{novel.chapters}</span>
|
||||
{/if}
|
||||
{#if novel.rating}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-400">★ {novel.rating}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">★ {novel.rating}</span>
|
||||
{/if}
|
||||
{#if novel.genres?.length}
|
||||
{#each novel.genres.slice(0, 3) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -626,18 +635,18 @@
|
||||
{#if data.isAdmin && novel.url}
|
||||
<div class="shrink-0">
|
||||
{#if scrapeResult[novel.slug] === 'queued'}
|
||||
<span class="text-xs text-emerald-400 font-medium">Queued</span>
|
||||
<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">Busy</span>
|
||||
<span class="text-xs text-yellow-400 font-medium">{m.catalogue_scrape_busy_list()}</span>
|
||||
{:else if scrapeResult[novel.slug] === 'error'}
|
||||
<span class="text-xs text-red-400 font-medium">Error</span>
|
||||
<span class="text-xs text-(--color-danger) font-medium">{m.common_error()}</span>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => scrapeNovel(novel)}
|
||||
disabled={scraping[novel.slug]}
|
||||
class="text-xs px-2.5 py-1 rounded bg-amber-500/20 text-amber-300 hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30 whitespace-nowrap"
|
||||
class="text-xs px-2.5 py-1 rounded bg-amber-500/20 text-(--color-brand-dim) hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30 whitespace-nowrap"
|
||||
>
|
||||
{scraping[novel.slug] ? 'Scraping…' : 'Scrape'}
|
||||
{scraping[novel.slug] ? m.catalogue_scraping_novel() : m.catalogue_scrape_novel_button()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -649,7 +658,7 @@
|
||||
href={novel.source_url ?? novel.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="shrink-0 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
class="shrink-0 text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||
title="Open on novelfire.net"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -666,20 +675,18 @@
|
||||
<!-- Infinite scroll sentinel (browse mode only — not rank, not search) -->
|
||||
{#if !isRankView && !isSearchView}
|
||||
{#if hasNext}
|
||||
<!-- Invisible div watched by IntersectionObserver -->
|
||||
<div bind:this={sentinel} class="h-px mt-8"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading spinner while fetching next page -->
|
||||
{#if loadingMore}
|
||||
<div class="flex justify-center py-8">
|
||||
<svg class="w-6 h-6 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
|
||||
<svg class="w-6 h-6 animate-spin text-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
{:else if !hasNext && novels.length > 0}
|
||||
<p class="text-center text-zinc-600 text-xs mt-8 pb-4">All novels loaded</p>
|
||||
<p class="text-center text-(--color-muted) text-xs mt-8 pb-4">{m.catalogue_all_loaded()}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -687,9 +694,9 @@
|
||||
{#if showScrollTop}
|
||||
<button
|
||||
onclick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
class="fixed bottom-6 right-6 z-50 p-3 rounded-full bg-zinc-800 border border-zinc-600 text-zinc-300 shadow-lg hover:bg-zinc-700 hover:text-zinc-100 transition-colors"
|
||||
title="Back to top"
|
||||
aria-label="Scroll to top"
|
||||
class="fixed bottom-6 right-6 z-50 p-3 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-text) shadow-lg hover:bg-(--color-surface-3) hover:text-(--color-text) transition-colors"
|
||||
title={m.catalogue_scroll_top()}
|
||||
aria-label={m.catalogue_scroll_top()}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Disclaimer — libnovel</title>
|
||||
<title>{m.disclaimer_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto py-10 px-4">
|
||||
<h1 class="text-2xl font-bold text-zinc-100 mb-6">Disclaimer</h1>
|
||||
<h1 class="text-2xl font-bold text-(--color-text) mb-6">Disclaimer</h1>
|
||||
|
||||
<div class="space-y-5 text-sm text-zinc-400 leading-relaxed">
|
||||
<div class="space-y-5 text-sm text-(--color-muted) leading-relaxed">
|
||||
<p>
|
||||
libnovel is a personal reading tool that indexes and caches publicly accessible novel content
|
||||
from third-party sources, primarily <a href="https://novelfire.net" target="_blank" rel="noopener noreferrer" class="text-amber-400 hover:text-amber-300 transition-colors">novelfire.net</a>.
|
||||
from third-party sources, primarily <a href="https://novelfire.net" target="_blank" rel="noopener noreferrer" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">novelfire.net</a>.
|
||||
It is not affiliated with, endorsed by, or in any way officially connected to those sources.
|
||||
</p>
|
||||
|
||||
@@ -20,7 +24,7 @@
|
||||
|
||||
<p>
|
||||
If you are a rights holder and believe your work is being used without authorisation, please
|
||||
refer to our <a href="/dmca" class="text-amber-400 hover:text-amber-300 transition-colors">DMCA policy</a>
|
||||
refer to our <a href="/dmca" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">DMCA policy</a>
|
||||
for instructions on how to request removal.
|
||||
</p>
|
||||
|
||||
@@ -29,6 +33,6 @@
|
||||
content displayed. Use of this site is at your own risk.
|
||||
</p>
|
||||
|
||||
<p class="text-zinc-600 text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
|
||||
<p class="text-(--color-muted) text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user