Compare commits

...

21 Commits

Author SHA1 Message Date
Admin
278e292956 fix(home): use book.summary instead of book.description in hero card
Some checks failed
CI / Backend (push) Successful in 1m3s
CI / UI (push) Successful in 40s
Release / Docker / caddy (push) Failing after 10s
Release / Test backend (push) Successful in 40s
CI / UI (pull_request) Successful in 41s
CI / Backend (pull_request) Successful in 59s
Release / Docker / runner (push) Failing after 38s
Release / Docker / backend (push) Successful in 3m35s
Release / Check ui (push) Successful in 1m1s
Release / Docker / ui (push) Successful in 2m50s
Release / Gitea Release (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:44:25 +05:00
Admin
76de5eb491 feat(reader): chapter comments + readers-this-week count
Some checks failed
CI / Backend (push) Successful in 48s
CI / UI (push) Failing after 22s
Release / Check ui (push) Failing after 33s
Release / Docker / ui (push) Has been skipped
Release / Test backend (push) Successful in 53s
Release / Docker / caddy (push) Successful in 48s
CI / Backend (pull_request) Successful in 44s
CI / UI (pull_request) Failing after 44s
Release / Docker / runner (push) Failing after 46s
Release / Docker / backend (push) Successful in 1m54s
Release / Gitea Release (push) Has been skipped
- CommentsSection now accepts a chapter prop and scopes comments to that chapter
- Chapter reader page mounts CommentsSection with current chapter number
- Book detail page shows rolling 7-day unique reader count badge
- API GET/POST pass chapter param; pocketbase listComments filters by chapter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:34:12 +05:00
Admin
c6597c8d19 feat(home): hero resume card, horizontal scroll rows, genre strip, dedup
Some checks failed
CI / Backend (push) Successful in 1m0s
CI / UI (push) Failing after 26s
Release / Test backend (push) Successful in 53s
CI / Backend (pull_request) Successful in 45s
Release / Docker / caddy (push) Successful in 1m13s
CI / UI (pull_request) Failing after 33s
Release / Docker / runner (push) Failing after 1m27s
Release / Docker / backend (push) Successful in 3m35s
Release / Check ui (push) Failing after 31s
Release / Docker / ui (push) Has been skipped
Release / Gitea Release (push) Has been skipped
- First continue-reading book becomes a wide hero card with title,
  description, genre tags, and a prominent Resume ch.N CTA
- Remaining in-progress books move to a horizontal scroll shelf
- Recently Updated deduplicates by slug; books with multiple new
  chapters show a green "+N ch." badge
- Genre discovery strip (horizontal scroll) links to /catalogue?genre=X
- Stats demoted to a subtle two-number footer bar
- All rows use horizontal scroll instead of fixed grids

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:20:28 +05:00
Admin
e8d7108753 feat(themes): add light, light-slate, and light-rose themes
Some checks failed
CI / Backend (push) Successful in 30s
Release / Test backend (push) Failing after 11s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
CI / UI (push) Successful in 35s
Release / Docker / caddy (push) Successful in 58s
Release / Check ui (push) Successful in 1m7s
CI / Backend (pull_request) Successful in 31s
CI / UI (pull_request) Successful in 1m1s
Release / Docker / ui (push) Failing after 2m43s
Release / Gitea Release (push) Has been skipped
Three light variants mirroring the existing dark set. All use CSS custom
properties so no component changes are needed. Theme dots in the header
and mobile drawer show a separator between dark/light groups; light-theme
swatches get a subtle ring so they're visible on light backgrounds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:13:25 +05:00
Admin
90dbecfa17 feat(profile): auto-save settings, merge sections, fix font/size preview
Some checks failed
CI / Backend (push) Successful in 30s
Release / Test backend (push) Successful in 33s
Release / Docker / caddy (push) Failing after 10s
CI / UI (push) Successful in 52s
Release / Check ui (push) Successful in 1m7s
CI / Backend (pull_request) Successful in 40s
CI / UI (pull_request) Successful in 53s
Release / Docker / runner (push) Failing after 46s
Release / Docker / backend (push) Successful in 3m16s
Release / Docker / ui (push) Successful in 5m2s
Release / Gitea Release (push) Has been skipped
- Remove both "Save settings" buttons; all settings now auto-save with
  800ms debounce and show a transient "✓ Saved" indicator
- Apply theme, font family, and font size to context immediately on
  change so the preview is live without waiting for the save
- Merge Appearance + Reading settings into a single Preferences card
  with dividers — fewer sections, less visual noise
- Pro users see a compact subscription row; free users see upgrade CTAs
- Speed label splits value and units (shows "1.5x" separately in brand
  color) for cleaner readout
- Auto-advance toggle gains a subtitle description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:07:51 +05:00
Admin
2deb306419 fix(i18n+settings): rename pt-BR→pt, fix theme/locale persistence
Some checks failed
CI / Backend (push) Successful in 56s
CI / UI (push) Successful in 38s
Release / Test backend (push) Successful in 42s
Release / Docker / caddy (push) Failing after 11s
CI / Backend (pull_request) Failing after 11s
Release / Docker / backend (push) Failing after 38s
CI / UI (pull_request) Successful in 44s
Release / Check ui (push) Successful in 1m53s
Release / Docker / runner (push) Failing after 1m26s
Release / Docker / ui (push) Successful in 3m46s
Release / Gitea Release (push) Has been skipped
Root cause: user_settings table was missing theme, locale, font_family,
font_size columns — PocketBase silently dropped them on every save.
Added the four columns via PocketBase API.

Also:
- listOne now sorts by -updated so the most-recent settings record wins
- PARAGLIDE_LOCALE cookie is now cleared when switching back to English
- pt-BR renamed to pt throughout (messages, inlang settings, validLocales)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:05:14 +05:00
Admin
fd283bf6c6 fix(sessions): prune stale sessions on login to prevent accumulation
Some checks failed
CI / Backend (push) Successful in 43s
CI / UI (push) Successful in 36s
Release / Test backend (push) Successful in 56s
Release / Check ui (push) Successful in 50s
Release / Docker / caddy (push) Successful in 40s
CI / UI (pull_request) Failing after 37s
CI / Backend (pull_request) Successful in 48s
Release / Docker / runner (push) Failing after 32s
Release / Docker / backend (push) Successful in 2m39s
Release / Docker / ui (push) Successful in 1m45s
Release / Gitea Release (push) Has been skipped
Sessions not seen in 30+ days are deleted in the background each time
a new session is created. No cron job needed — self-cleaning on login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:24:16 +05:00
Admin
3154a22500 fix(errors): redesign all Caddy error pages with consistent branded layout
Some checks failed
CI / UI (push) Successful in 43s
Release / Test backend (push) Successful in 45s
CI / Backend (push) Successful in 48s
Release / Check ui (push) Successful in 36s
CI / Backend (pull_request) Successful in 47s
CI / UI (pull_request) Successful in 36s
Release / Docker / caddy (push) Failing after 1m50s
Release / Docker / runner (push) Successful in 1m57s
Release / Docker / backend (push) Successful in 3m13s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Has been skipped
Add header/footer, LibNovel wordmark, pulsing status indicator, faint
watermark code, and auto-refresh countdown (20s for 502/504, 30s for 503).
Fix two-tone background by setting background on html+body. 404 is static
with no auto-refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:22:14 +05:00
Admin
61e0d98057 feat(ui): declutter header — user menu + lang dropdown, mobile theme/lang
Some checks failed
CI / Backend (pull_request) Successful in 55s
CI / UI (pull_request) Successful in 49s
CI / UI (push) Failing after 35s
Release / Test backend (push) Successful in 42s
CI / Backend (push) Successful in 57s
Release / Check ui (push) Successful in 36s
Release / Docker / caddy (push) Successful in 1m11s
Release / Docker / runner (push) Successful in 2m5s
Release / Docker / ui (push) Successful in 2m2s
Release / Docker / backend (push) Failing after 2m32s
Release / Gitea Release (push) Has been skipped
Desktop header before: logo · Library · Catalogue · Feedback · ●●● · EN RU ID PT-BR FR · Admin · username · Sign out
Desktop header after:  logo · Library · Catalogue · Feedback · ●●● · [🌐 EN ▾] · [avatar ▾]

Changes:
- Theme dots kept but made slightly smaller (3.5 → 3.5, less gap)
- 5 bare language codes replaced with a compact globe + current locale
  dropdown (click to expand all 5 options)
- Admin link, username, and Sign out collapsed into a single user-initial
  avatar dropdown (Profile / Admin panel / Sign out)
- Click-outside overlay closes any open dropdown
- Mobile drawer: added Theme and Language rows so users can switch both
  without opening the desktop header
- Footer locale switcher removed (redundant with header and mobile drawer)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:54:46 +05:00
Admin
601c26d436 fix(ui): theme token consistency, seek bar a11y, colour polish
All checks were successful
CI / Backend (push) Successful in 36s
CI / Backend (pull_request) Successful in 46s
CI / UI (push) Successful in 59s
CI / UI (pull_request) Successful in 37s
- Add --color-success variable to all three themes
- Replace hard-coded amber/green Tailwind colours with CSS variables:
    Pro badge + discount badge: bg-amber-400/15 → bg-(--color-brand)/15
    Saved confirmation:         text-green-400 → text-(--color-success)
    Catalogue flash messages:   emerald/yellow → success/brand tokens
    Login hover border:         border-zinc-600 → border-(--color-brand)/50
- Seek bar in mini-player now has aria-label (player_seek_label)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:24:34 +05:00
Admin
4a267d8fd8 feat: open catalogue + book pages to public (hybrid open model)
All checks were successful
CI / Backend (pull_request) Successful in 47s
CI / UI (pull_request) Successful in 44s
CI / Backend (push) Successful in 44s
Release / Test backend (push) Successful in 28s
CI / UI (push) Successful in 1m12s
Release / Check ui (push) Successful in 56s
Release / Docker / caddy (push) Successful in 1m9s
Release / Docker / ui (push) Successful in 1m57s
Release / Docker / runner (push) Successful in 3m45s
Release / Docker / backend (push) Successful in 3m50s
Release / Gitea Release (push) Successful in 14s
Reading content is now publicly accessible without login:
- /catalogue, /books/[slug], /books/[slug]/chapters, /books/[slug]/chapters/[n]
  are all public — no login redirect
- /books (personal library), /profile, /admin remain protected

Unauthenticated UX:
- Chapter page: "Audio narration available — Sign in to listen" banner
  replaces the audio player for logged-out visitors
- Book detail: bookmark icon links to /login instead of triggering
  the save action (both desktop and mobile CTAs)

SEO:
- robots.txt updated: Allow /books/ and /catalogue, Disallow /books (library)
- sitemap.xml now includes /catalogue as primary crawl entry point

i18n: added reader_signin_for_audio, reader_signin_audio_desc,
      book_detail_signin_to_save in all 5 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:16:22 +05:00
Admin
c9478a67fb fix(ci): use full gitea.com URL for gitea-release-action
All checks were successful
CI / UI (push) Successful in 41s
CI / Backend (push) Successful in 50s
Release / Test backend (push) Successful in 56s
Release / Check ui (push) Successful in 34s
CI / Backend (pull_request) Successful in 46s
CI / UI (pull_request) Successful in 34s
Release / Docker / runner (push) Successful in 3m15s
Release / Docker / backend (push) Successful in 3m29s
Release / Docker / ui (push) Successful in 2m30s
Release / Docker / caddy (push) Successful in 1m17s
Release / Gitea Release (push) Successful in 17s
Bare `actions/` references resolve to github.com by default in act_runner.
gitea-release-action lives on gitea.com so must use the full https:// URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:38:36 +05:00
Admin
1b4835daeb fix(homelab): switch Fider SMTP to port 587 + STARTTLS
All checks were successful
CI / Backend (pull_request) Successful in 50s
CI / UI (pull_request) Successful in 1m10s
Port 465 (SMTPS) is blocked on the homelab server. Port 587 with STARTTLS
works. Updated FIDER_SMTP_PORT=587 and FIDER_SMTP_ENABLE_STARTTLS=true in
Doppler prd_homelab, and made EMAIL_SMTP_ENABLE_STARTTLS dynamic so it reads
from Doppler instead of being hardcoded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:16:01 +05:00
Admin
c9c12fc4a8 fix(infra): correct doppler entrypoint for watchtower container
All checks were successful
CI / UI (pull_request) Successful in 39s
CI / Backend (pull_request) Successful in 45s
- Fix binary path: /usr/bin/doppler (not /usr/local/bin)
- Mount /root/.doppler config so the container can auth without DOPPLER_TOKEN env
- Set HOME=/root so doppler locates the mounted config directory
- Add explicit --project/--config flags to override directory-scope lookup
- Production: --project libnovel --config prd
- Homelab: --project libnovel --config prd_homelab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:05:33 +05:00
Admin
dd35024d02 chore(infra): run watchtower via doppler for fresh secrets on restart
All checks were successful
CI / UI (pull_request) Successful in 39s
CI / Backend (pull_request) Successful in 47s
Mount the host doppler binary into the watchtower container and use it as
the entrypoint so WATCHTOWER_NOTIFICATION_URL and other secrets are fetched
from Doppler each time the container starts, rather than being baked in at
compose-up time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:52:32 +05:00
Admin
4b8104f087 feat(ui): language persistence, theme fix, font/size settings, header quick-access
Some checks failed
CI / UI (push) Failing after 54s
CI / Backend (push) Successful in 1m56s
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 50s
Release / Docker / caddy (push) Successful in 54s
CI / Backend (pull_request) Successful in 45s
CI / UI (pull_request) Successful in 50s
Release / Docker / ui (push) Successful in 2m23s
Release / Docker / runner (push) Successful in 3m15s
Release / Docker / backend (push) Successful in 2m3s
Release / Gitea Release (push) Failing after 2s
- Fix language not persisting after refresh: save locale in user_settings,
  set PARAGLIDE_LOCALE cookie from DB preference on server load
- Fix theme change not applying: context setter was mismatched (setTheme vs current)
- Add font family (system/serif/mono) and text size (sm/md/lg/xl) user settings
  stored in DB and applied via CSS custom properties
- Add theme color dots and language picker to desktop header for quick access
- Footer locale switcher now saves preference to DB before switching
- Remove change password section (OAuth-only, no password login)
- Fix active sessions piling up: reuse existing session on re-login via OAuth
- Extend speed step cycle to include 2.5× and 3.0× (matching profile slider)
- Replace plain checkbox with modern toggle switch for auto-next setting
- Fix catalogue status labels (ongoing/completed) to use translation keys
- Add font family and text size translation keys to all 5 locale files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:43:00 +05:00
Admin
5da880d189 fix(runner): add heartbeat + translation polling to asynq mode
All checks were successful
CI / UI (push) Successful in 57s
CI / Backend (pull_request) Successful in 1m4s
CI / Backend (push) Successful in 1m45s
CI / UI (pull_request) Successful in 51s
Two bugs prevented asynq mode from working correctly on the homelab runner:

1. No healthcheck file: asynq mode never writes /tmp/runner.alive, so
   Docker healthcheck always fails. Added heartbeat goroutine that
   writes the file every StaleTaskThreshold (30s).

2. Translation tasks not dispatched: translation uses ClaimNextTranslationTask
   (PocketBase poll queue), not Redis/asynq. Audio + scrape use asynq mux,
   but translation sits in PocketBase forever. Added pollTranslationTasks()
   goroutine that polls PocketBase on the same PollInterval as the old
   poll() loop.

All Go tests pass (go test ./... in backend/).
2026-03-29 20:22:37 +05:00
Admin
98631df47a feat(billing): Polar.sh Pro subscription integration
Some checks failed
CI / UI (push) Successful in 1m36s
CI / Backend (push) Successful in 59s
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 33s
CI / Backend (pull_request) Successful in 44s
CI / UI (pull_request) Successful in 34s
Release / Docker / runner (push) Successful in 2m44s
Release / Docker / ui (push) Successful in 2m45s
Release / Docker / backend (push) Successful in 3m35s
Release / Docker / caddy (push) Successful in 1m8s
Release / Gitea Release (push) Failing after 2s
- Webhook handler verifies HMAC-SHA256 sig and updates user role on
  subscription.created / subscription.updated / subscription.revoked
- Audio endpoint gated: free users limited to 3 chapters/day via Valkey
  counter; returns 402 {error:'pro_required'} when limit reached
- Translation proxy endpoint enforces 402 for non-pro users
- AudioPlayer.svelte surfaces 402 via onProRequired callback + upgrade banner
- Chapter page shows lock icon + upgrade prompts for gated translation langs
- Profile page: subscription section shows Pro badge + manage link (active)
  or monthly/annual checkout buttons (free); isPro resolved fresh from DB
- i18n: 13 new profile_subscription_* keys across all 5 locales
2026-03-29 13:21:23 +05:00
Admin
83b3dccc41 fix(ui): run paraglide compile in prepare so CI type-check finds $lib/paraglide/server
Some checks failed
CI / Backend (pull_request) Successful in 50s
CI / UI (pull_request) Successful in 1m0s
CI / UI (push) Successful in 42s
Release / Test backend (push) Successful in 46s
CI / Backend (push) Successful in 1m1s
Release / Check ui (push) Successful in 34s
Release / Docker / caddy (push) Successful in 1m4s
Release / Docker / runner (push) Successful in 2m7s
Release / Docker / ui (push) Successful in 2m26s
Release / Docker / backend (push) Successful in 3m28s
Release / Gitea Release (push) Failing after 2s
The paraglide output dir has its own .gitignore (ignores everything), so
generated files are never committed. CI ran `npm ci` then `svelte-check`
without ever invoking vite, so $lib/paraglide/server was missing at
type-check time.

Adding `paraglide-js compile` to the prepare script ensures the output is
regenerated during `npm ci` before svelte-kit sync and svelte-check run.

Also adds translation_jobs collection to pb-init-v3.sh.
2026-03-29 11:50:06 +05:00
Admin
588e455aae chore(homelab): add LibreTranslate service to runner compose
Some checks failed
CI / Backend (pull_request) Failing after 11s
CI / UI (pull_request) Failing after 11s
- libretranslate/libretranslate:latest, internal Docker network only
- LT_LOAD_ONLY=en,ru,id,pt,fr (only pairs the runner needs)
- LT_API_KEYS=true, key stored in Doppler prd_homelab
- Runner depends_on libretranslate (service_healthy)
- LIBRETRANSLATE_URL=http://libretranslate:5000 (no tunnel needed)
- RUNNER_MAX_CONCURRENT_TRANSLATION wired from Doppler
2026-03-29 11:42:52 +05:00
Admin
28ac8d8826 feat(translation): machine translation pipeline + admin bulk enqueue UI
Some checks failed
CI / Backend (push) Failing after 11s
Release / Check ui (push) Failing after 51s
Release / Docker / ui (push) Has been skipped
CI / UI (push) Failing after 55s
Release / Test backend (push) Failing after 1m9s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
Release / Docker / caddy (push) Failing after 28s
Release / Gitea Release (push) Has been skipped
CI / UI (pull_request) Failing after 42s
CI / Backend (pull_request) Successful in 3m45s
- LibreTranslate client (chunks on blank lines, ≤4500 chars, 3-goroutine semaphore)
- Runner translation task loop (OTel, heartbeat, MinIO storage)
- PocketBase translation_jobs collection support (create/claim/finish/list)
- Per-chapter language switcher on chapter reader (EN/RU/ID/PT/FR, polls until done)
- Admin /admin/translation page: bulk enqueue form + live-polling jobs table
- New backend routes: POST /api/translation/{slug}/{n}, GET /api/translation/status,
  GET /api/translation/{slug}/{n}, GET /api/admin/translation/jobs,
  POST /api/admin/translation/bulk
- ListTranslationTasks added to taskqueue.Reader interface + store impl
- All builds and tests pass; svelte-check: 0 errors
2026-03-29 11:32:42 +05:00
68 changed files with 3702 additions and 743 deletions

View File

@@ -279,7 +279,7 @@ jobs:
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

Binary file not shown.

View File

@@ -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,
},
)

View File

@@ -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,6 +158,7 @@ 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,
@@ -170,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)

View File

@@ -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

View File

@@ -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=

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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())})

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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
@@ -135,15 +149,16 @@ type Runner struct {
// 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
}
@@ -166,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{
@@ -195,6 +211,7 @@ 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),

View File

@@ -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"`
}

View 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
}

View File

@@ -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)
}
}

View File

@@ -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
@@ -95,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.
@@ -107,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
}
@@ -137,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"
}
@@ -175,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,
)
@@ -208,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)
@@ -227,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():
@@ -252,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.
@@ -335,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.

View File

@@ -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

View 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)
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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

Binary file not shown.

View File

@@ -3,49 +3,137 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>404 — Page Not Found</title>
<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;
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: #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.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; }
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">404</div>
<h1>Page Not Found</h1>
<p>The page you're looking for doesn't exist or has been moved.</p>
<a href="/">Go home</a>
<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>
&copy; LibNovel
</footer>
</body>
</html>

View File

@@ -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>
&copy; 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>

View File

@@ -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&rsquo;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>
&copy; 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>

View File

@@ -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>
&copy; 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>

View File

@@ -401,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:

View File

@@ -221,7 +221,7 @@ 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}"
@@ -443,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:

View File

@@ -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:

View File

@@ -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"

View File

@@ -48,6 +48,7 @@
"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",
@@ -99,6 +100,7 @@
"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",
@@ -123,6 +125,8 @@
"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",
@@ -156,6 +160,9 @@
"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",
@@ -329,6 +336,18 @@
"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}",
@@ -380,5 +399,15 @@
"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"
"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"
}

View File

@@ -48,6 +48,7 @@
"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é",
@@ -99,6 +100,7 @@
"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",
@@ -123,6 +125,8 @@
"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",
@@ -156,6 +160,9 @@
"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",
@@ -329,6 +336,18 @@
"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}",
@@ -379,5 +398,15 @@
"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…"
"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"
}

View File

@@ -48,6 +48,7 @@
"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",
@@ -99,6 +100,7 @@
"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",
@@ -123,6 +125,8 @@
"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",
@@ -156,6 +160,9 @@
"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",
@@ -329,6 +336,18 @@
"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}",
@@ -379,5 +398,15 @@
"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…"
"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"
}

View File

@@ -48,6 +48,7 @@
"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",
@@ -99,6 +100,7 @@
"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",
@@ -123,6 +125,8 @@
"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",
@@ -156,6 +160,9 @@
"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",
@@ -329,6 +336,18 @@
"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}",
@@ -379,5 +398,15 @@
"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…"
"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"
}

View File

@@ -48,6 +48,7 @@
"player_play": "Воспроизвести",
"player_pause": "Пауза",
"player_speed_label": "Скорость {speed}x",
"player_seek_label": "Прогресс главы",
"player_change_speed": "Изменить скорость",
"player_auto_next_on": "Автопереход вкл.",
"player_auto_next_off": "Автопереход выкл.",
@@ -99,6 +100,7 @@
"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": "Читать",
@@ -123,6 +125,8 @@
"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": "Следующая глава",
@@ -156,6 +160,9 @@
"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": "Скорость воспроизведения",
@@ -329,6 +336,18 @@
"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}",
@@ -379,5 +398,15 @@
"reader_generate_samples": "Сгенерировать недостающие образцы",
"reader_voice_applies_next": "Новый голос применится при следующем нажатии «Воспроизвести».",
"reader_choose_voice": "Выбрать голос",
"reader_generating_narration": "Генерация озвучки…"
"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": "Очень большой"
}

View File

@@ -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"
},

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "ru", "id", "pt-BR", "fr"],
"locales": ["en", "ru", "id", "pt", "fr"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format/dist/index.js"
],

View File

@@ -9,7 +9,8 @@
--color-muted: #a1a1aa; /* zinc-400 */
--color-text: #f4f4f5; /* zinc-100 */
--color-border: #3f3f46; /* zinc-700 */
--color-danger: #f87171; /* red-400 */
--color-danger: #f87171; /* red-400 */
--color-success: #4ade80; /* green-400 */
}
/* ── Amber theme (default) — same as @theme above, explicit for clarity ── */
@@ -23,6 +24,7 @@
--color-text: #f4f4f5;
--color-border: #3f3f46;
--color-danger: #f87171;
--color-success: #4ade80;
}
/* ── Slate theme — indigo/slate dark ─────────────────────────────────── */
@@ -36,6 +38,7 @@
--color-text: #f1f5f9; /* slate-100 */
--color-border: #334155; /* slate-700 */
--color-danger: #f87171; /* red-400 */
--color-success: #4ade80; /* green-400 */
}
/* ── Rose theme — dark pink ───────────────────────────────────────────── */
@@ -49,6 +52,49 @@
--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 {
@@ -56,11 +102,18 @@ 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;
font-family: var(--reading-font);
font-size: var(--reading-size);
color: var(--color-muted);
}

2
ui/src/app.d.ts vendored
View File

@@ -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 {}

View File

@@ -6,7 +6,7 @@ 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';
@@ -141,7 +141,7 @@ export function parseAuthToken(token: string): { id: string; username: string; r
// ─── Hook ─────────────────────────────────────────────────────────────────────
function getTextDirection(locale: string): string {
// All supported locales (en, ru, id, pt-BR, fr) are LTR
// All supported locales (en, ru, id, pt, fr) are LTR
return 'ltr';
}
@@ -210,6 +210,18 @@ const appHandle: 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);
};

View File

@@ -67,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 {
@@ -77,7 +79,8 @@
cover = '',
nextChapter = null,
chapters = [],
voices = []
voices = [],
onProRequired = undefined
}: Props = $props();
// ── Derived: voices grouped by engine ──────────────────────────────────
@@ -563,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) {

View File

@@ -6,10 +6,12 @@
import * as m from '$lib/paraglide/messages.js';
let {
slug,
chapter = 0,
isLoggedIn = false,
currentUserId = ''
}: {
slug: string;
chapter?: number; // 0 = book-level, N = chapter N
isLoggedIn?: boolean;
currentUserId?: string;
} = $props();
@@ -47,7 +49,7 @@
loadError = '';
try {
const res = await fetch(
`/api/comments/${encodeURIComponent(slug)}?sort=${sort}`
`/api/comments/${encodeURIComponent(slug)}?sort=${sort}${chapter > 0 ? `&chapter=${chapter}` : ''}`
);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
@@ -85,7 +87,7 @@
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: text })
body: JSON.stringify({ body: text, ...(chapter > 0 ? { chapter } : {}) })
});
if (res.status === 401) { postError = 'You must be logged in to comment.'; return; }
if (!res.ok) {

View File

@@ -55,6 +55,9 @@ export interface PBUserSettings {
voice: string;
speed: number;
theme?: string;
locale?: string;
font_family?: string;
font_size?: number;
updated?: string;
}
@@ -71,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 ─────────────────────────────────────────────────────────
@@ -192,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()}`
);
@@ -572,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.
@@ -779,7 +806,7 @@ export async function getSettings(
export async function saveSettings(
sessionId: string,
settings: { autoNext: boolean; voice: string; speed: number; theme?: string },
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 }>(
@@ -795,6 +822,9 @@ export async function saveSettings(
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) {
@@ -917,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,
@@ -964,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;
}
@@ -1000,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.
@@ -1058,6 +1130,7 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
export interface PBBookComment {
id: string;
slug: string;
chapter?: number; // 0 or absent = book-level; N = chapter N
user_id: string;
username: string;
body: string;
@@ -1078,25 +1151,26 @@ export interface CommentVote {
export type CommentSort = 'top' | 'new';
/**
* List top-level comments for a book.
* List top-level comments for a book or a specific chapter.
* chapter=0 (default) → book-level comments only
* chapter=N → comments for chapter N only
* sort='top' → by net score (upvotes downvotes) desc, then newest
* sort='new' → newest first (default)
* Replies (parent_id != "") are NOT included — fetch them separately.
*/
export async function listComments(
slug: string,
sort: CommentSort = 'new'
sort: CommentSort = 'new',
chapter = 0
): Promise<PBBookComment[]> {
const token = await getToken();
const slugEsc = slug.replace(/"/g, '\\"');
// Only top-level comments (parent_id is empty or missing)
const filter = encodeURIComponent(`slug="${slugEsc}"&&(parent_id=""||parent_id=null)`);
// PocketBase sorts: for 'top' we still fetch all and re-sort in JS because
// PocketBase doesn't support computed sort fields. For 'new' we push the
// sort down to the DB so large result sets are still paged correctly.
const pbSort = sort === 'new' ? '&sort=-created' : '&sort=-created';
const chapterFilter = chapter > 0
? `&&chapter=${chapter}`
: `&&(chapter=0||chapter=null)`;
const filter = encodeURIComponent(`slug="${slugEsc}"${chapterFilter}&&(parent_id=""||parent_id=null)`);
const res = await fetch(
`${PB_URL}/api/collections/book_comments/records?filter=${filter}${pbSort}&perPage=200`,
`${PB_URL}/api/collections/book_comments/records?filter=${filter}&sort=-created&perPage=200`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return [];
@@ -1107,13 +1181,32 @@ export async function listComments(
const scoreB = (b.upvotes ?? 0) - (b.downvotes ?? 0);
const scoreA = (a.upvotes ?? 0) - (a.downvotes ?? 0);
if (scoreB !== scoreA) return scoreB - scoreA;
// tie-break: newest first
return new Date(b.created).getTime() - new Date(a.created).getTime();
});
}
return items;
}
/**
* Count unique readers for a book in the last 7 days.
* Uses progress.updated timestamp; counts both session-based and user-based.
*/
export async function countReadersThisWeek(slug: string): Promise<number> {
const token = await getToken();
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const filter = encodeURIComponent(`slug="${slug.replace(/"/g, '\\"')}"&&updated>"${cutoff}"`);
const res = await fetch(
`${PB_URL}/api/collections/progress/records?filter=${filter}&perPage=500&fields=user_id,session_id`,
{ headers: { Authorization: `Bearer ${token}` } }
);
if (!res.ok) return 0;
const data = await res.json();
const items = (data.items ?? []) as { user_id?: string; session_id?: string }[];
// Deduplicate: prefer user_id when present, fall back to session_id
const unique = new Set(items.map((r) => r.user_id || r.session_id || '').filter(Boolean));
return unique.size;
}
/**
* List replies (1-level deep) for a single parent comment.
* Always sorted oldest-first so the conversation reads naturally.
@@ -1139,7 +1232,8 @@ export async function createComment(
body: string,
userId: string | undefined,
username: string,
parentId?: string
parentId?: string,
chapter = 0
): Promise<PBBookComment> {
const token = await getToken();
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
@@ -1147,6 +1241,7 @@ export async function createComment(
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
slug,
chapter,
body,
user_id: userId ?? '',
username,

107
ui/src/lib/server/polar.ts Normal file
View 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 });
}

View File

@@ -4,16 +4,20 @@ 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, theme: 'amber' };
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) {
@@ -21,15 +25,39 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
autoNext: row.auto_next ?? false,
voice: row.voice ?? 'af_bella',
speed: row.speed ?? 1.0,
theme: row.theme ?? 'amber'
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
};
};

View File

@@ -10,13 +10,26 @@
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
import * as m from '$lib/paraglide/messages.js';
import { locales, getLocale, localizeHref } from '$lib/paraglide/runtime.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);
@@ -26,11 +39,17 @@
// ── 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 state to child pages (e.g. profile theme picker)
// Expose theme + font state to child pages (e.g. profile picker)
setContext('theme', {
get current() { return currentTheme; },
set current(v: string) { currentTheme = v; }
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(() => {
@@ -39,6 +58,17 @@
}
});
$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;
@@ -50,19 +80,23 @@
audioStore.voice = data.settings.voice;
audioStore.speed = data.settings.speed;
}
// Always sync theme (profile page calls invalidateAll after saving)
// 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 four 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;
@@ -72,7 +106,7 @@
fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed, theme })
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize })
}).catch(() => {});
}, 800) as unknown as number;
});
@@ -168,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);
@@ -282,27 +316,104 @@
{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') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
{m.nav_admin()}
</a>
{/if}
<a
href="/profile"
class="hidden sm:block text-sm transition-colors {page.url.pathname === '/profile' ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
{data.user.username}
</a>
<form method="POST" action="/logout" class="hidden sm:block">
<Button type="submit" variant="ghost" size="sm" class="text-(--color-muted) hover:text-(--color-text)">
{m.nav_sign_out()}
</Button>
</form>
<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)'}"
>
<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)'}"
>
<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
@@ -314,18 +425,25 @@
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
@@ -382,6 +500,51 @@
{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
@@ -432,22 +595,6 @@
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<!-- Locale switcher (always visible) -->
<div class="hidden sm:flex items-center gap-1 ml-2">
{#each locales as locale}
<a
href={localizeHref(page.url.pathname, { locale })}
data-sveltekit-reload
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)'}"
aria-label="{m.locale_switcher_label()}: {locale}"
>
{locale.toUpperCase()}
</a>
{/each}
</div>
</nav>
<!-- Bottom row: legal links + copyright -->
<div class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-(--color-muted)">
@@ -540,6 +687,7 @@
<div class="px-0">
<input
type="range"
aria-label={m.player_seek_label()}
min="0"
max={audioStore.duration || 0}
value={audioStore.currentTime}

View File

@@ -10,194 +10,220 @@
try {
const parsed = JSON.parse(genres);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
} catch { return []; }
}
// Deduplicate recentlyUpdated by slug, keeping the first occurrence and
// counting how many times the same book appears (= new chapters added).
const dedupedRecent = $derived.by(() => {
const seen = new Map<string, { book: (typeof data.recentlyUpdated)[0]; count: number }>();
for (const book of data.recentlyUpdated) {
if (seen.has(book.slug)) {
seen.get(book.slug)!.count++;
} else {
seen.set(book.slug, { book, count: 1 });
}
}
return [...seen.values()];
});
const GENRES = [
'Action', 'Fantasy', 'Romance', 'Cultivation', 'System',
'Reincarnation', 'Sci-Fi', 'Horror', 'Slice of Life', 'Adventure',
];
// Hero = first continue-reading item; shelf = the rest
const heroBook = $derived(data.continueReading[0] ?? null);
const shelfBooks = $derived(data.continueReading.slice(1));
</script>
<svelte:head>
<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-(--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-(--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-(--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>
<!-- Continue Reading -->
{#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-(--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>
<!-- ── Hero resume card ──────────────────────────────────────────────────────── -->
{#if heroBook}
<section class="mb-10">
<a
href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all"
>
<!-- Cover -->
<div class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden">
{#if heroBook.book.cover}
<img src={heroBook.book.cover} alt={heroBook.book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" loading="eager" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-10 h-10 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
{/if}
</div>
<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-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden relative">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-(--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" />
</svg>
</div>
{/if}
<!-- Chapter badge overlay -->
<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) })}
<!-- Info -->
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0">
<div>
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
{#if heroBook.book.author}
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
{/if}
{#if heroBook.book.summary}
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.summary}</p>
{/if}
</div>
<div class="flex items-center gap-3 mt-4 flex-wrap">
<span class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm group-hover:bg-(--color-brand-dim) transition-colors">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
</span>
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
{/each}
</div>
</div>
</a>
</section>
{/if}
<!-- ── Continue Reading shelf (remaining books) ──────────────────────────────── -->
{#if shelfBooks.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each shelfBooks as { book, chapter }}
<a href="/books/{book.slug}/chapters/{chapter}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-32 sm:w-36">
<div class="aspect-[2/3] overflow-hidden relative">
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
</div>
{/if}
<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-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- ── Genre discovery strip ─────────────────────────────────────────────────── -->
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">Browse by genre</h2>
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
</div>
<div class="flex gap-2 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4">
{#each GENRES as genre}
<a href="/catalogue?genre={encodeURIComponent(genre)}"
class="shrink-0 px-3.5 py-1.5 rounded-full border border-(--color-border) bg-(--color-surface-2) text-sm text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors whitespace-nowrap">
{genre}
</a>
{/each}
</div>
</section>
<!-- ── Recently Updated ──────────────────────────────────────────────────────── -->
{#if dedupedRecent.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each dedupedRecent as { book, count }}
{@const genres = parseGenres(book.genres)}
<a href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
<div class="aspect-[2/3] overflow-hidden relative">
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
</div>
{/if}
{#if count > 1}
<span class="absolute top-1.5 left-1.5 text-xs bg-(--color-success)/90 text-black font-bold px-1.5 py-0.5 rounded">
+{count} ch.
</span>
</div>
<div class="p-2">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author}
<p class="text-xs text-(--color-muted) truncate mt-0.5">{book.author}</p>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- Recently Updated -->
{#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-(--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-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-(--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" />
</svg>
</div>
{/if}
</div>
<div class="p-2 flex flex-col gap-1">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author}
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
{/if}
{#if book.status}
<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-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- Empty state -->
{#if data.continueReading.length === 0 && data.recentlyUpdated.length === 0}
<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-(--color-brand) text-(--color-surface) font-semibold rounded hover:bg-(--color-brand-dim) transition-colors"
>
{m.home_discover_novels()}
</a>
{/if}
</div>
<div class="p-2 flex flex-col gap-1">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author}
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
{/if}
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-0.5">
{#each genres.slice(0, 2) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- From Subscriptions -->
<!-- ── From Following ────────────────────────────────────────────────────────── -->
{#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-(--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-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-(--color-surface) overflow-hidden">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-(--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" />
</svg>
</div>
{/if}
</div>
<div class="p-2 flex flex-col gap-1">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author}
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
{/if}
<!-- Reader attribution -->
<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-(--color-surface) text-(--color-muted)">{genre}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
</section>
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_from_following()}</h2>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.subscriptionFeed as { book, readerUsername }}
<a href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
<div class="aspect-[2/3] overflow-hidden">
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
</div>
{/if}
</div>
<div class="p-2 flex flex-col gap-0.5">
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
<p class="text-xs text-(--color-muted) truncate">{m.home_via_reader({ username: readerUsername })}</p>
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- ── Empty state (no content at all) ──────────────────────────────────────── -->
{#if data.continueReading.length === 0 && dedupedRecent.length === 0}
<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-(--color-brand) text-(--color-surface) font-semibold rounded-lg hover:bg-(--color-brand-dim) transition-colors">
{m.home_discover_novels()}
</a>
</div>
{/if}
<!-- ── Stats footer ──────────────────────────────────────────────────────────── -->
<div class="mt-6 pt-6 border-t border-(--color-border) flex items-center justify-center gap-6 text-sm text-(--color-muted)">
<span><span class="font-semibold text-(--color-text)">{data.stats.totalBooks.toLocaleString()}</span> {m.home_stat_books()}</span>
<span class="w-px h-4 bg-(--color-border)"></span>
<span><span class="font-semibold text-(--color-text)">{data.stats.totalChapters.toLocaleString()}</span> {m.home_stat_chapters()}</span>
</div>

View File

@@ -5,6 +5,7 @@
const internalLinks = [
{ href: '/admin/scrape', label: 'Scrape' },
{ href: '/admin/audio', label: 'Audio' },
{ href: '/admin/translation', label: 'Translation' },
{ href: '/admin/changelog', label: 'Changelog' }
];

View 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) };
}
}
};

View 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' : ''} &middot;
<span class="text-green-400">{stats.done} done</span>
{#if stats.failed > 0}
&middot; <span class="text-(--color-danger)">{stats.failed} failed</span>
{/if}
{#if stats.inFlight > 0}
&middot; <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>

View File

@@ -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' } }
);
};

View File

@@ -21,9 +21,10 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
const { slug } = params;
const sortParam = url.searchParams.get('sort') ?? 'new';
const sort: CommentSort = sortParam === 'top' ? 'top' : 'new';
const chapter = parseInt(url.searchParams.get('chapter') ?? '0', 10) || 0;
try {
const topLevel = await listComments(slug, sort);
const topLevel = await listComments(slug, sort, chapter);
// Fetch replies for all top-level comments in parallel
const repliesPerComment = await Promise.all(topLevel.map((c) => listReplies(c.id)));
@@ -75,7 +76,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
if (!locals.user) error(401, 'Login required to comment');
const { slug } = params;
let body: { body?: string; parent_id?: string };
let body: { body?: string; parent_id?: string; chapter?: number };
try {
body = await request.json();
} catch {
@@ -86,8 +87,8 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
if (!text) error(400, 'Comment body is required');
if (text.length > 2000) error(400, 'Comment is too long (max 2000 characters)');
// Enforce 1-level depth: parent_id must be a top-level comment
const parentId = body.parent_id?.trim() || undefined;
const chapter = typeof body.chapter === 'number' ? body.chapter : 0;
try {
const comment = await createComment(
@@ -95,7 +96,8 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
text,
locals.user.id,
locals.user.username,
parentId
parentId,
chapter
);
return json(comment, { status: 201 });
} catch (e) {

View File

@@ -5,7 +5,7 @@ import { log } from '$lib/server/logger';
/**
* GET /api/settings
* Returns the current user's settings (auto_next, voice, speed, theme).
* 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 }) => {
@@ -15,7 +15,10 @@ export const GET: RequestHandler = async ({ locals }) => {
autoNext: settings?.auto_next ?? false,
voice: settings?.voice ?? 'af_bella',
speed: settings?.speed ?? 1.0,
theme: settings?.theme ?? 'amber'
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) });
@@ -25,7 +28,7 @@ export const GET: RequestHandler = async ({ locals }) => {
/**
* PUT /api/settings
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string }
* 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 }) => {
@@ -41,11 +44,29 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
}
// theme is optional — if provided it must be a known value
const validThemes = ['amber', 'slate', 'rose'];
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) {

View 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' }
});
};

View 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' }
});
};

View 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 });
};

View File

@@ -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, {

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getBook, listChapterIdx, getProgress, isBookSaved } from '$lib/server/pocketbase';
import { getBook, listChapterIdx, getProgress, isBookSaved, countReadersThisWeek } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper';
@@ -15,12 +15,13 @@ export const load: PageServerLoad = async ({ params, locals }) => {
if (book) {
// Book is in the library — normal path
let chapters, progress, saved;
let chapters, progress, saved, readersThisWeek;
try {
[chapters, progress, saved] = await Promise.all([
[chapters, progress, saved, readersThisWeek] = await Promise.all([
listChapterIdx(slug),
getProgress(locals.sessionId, slug, locals.user?.id),
isBookSaved(locals.sessionId, slug, locals.user?.id)
isBookSaved(locals.sessionId, slug, locals.user?.id),
countReadersThisWeek(slug)
]);
} catch (e) {
log.error('books', 'failed to load book page data', { slug, err: String(e) });
@@ -33,6 +34,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
inLib: true,
saved,
lastChapter: progress?.chapter ?? null,
readersThisWeek,
isAdmin: locals.user?.role === 'admin',
isLoggedIn: !!locals.user,
currentUserId: locals.user?.id ?? '',

View File

@@ -141,7 +141,7 @@
<p class="text-(--color-muted) text-sm mt-1">
{m.book_detail_scraping_progress()}
</p>
{#if data.taskId}
{#if data.taskId && data.user?.role === 'admin'}
<p class="text-(--color-muted) text-xs mt-2 font-mono">task: {data.taskId}</p>
{/if}
</div>
@@ -203,6 +203,12 @@
{#each genres as genre}
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border)">{genre}</span>
{/each}
{#if data.readersThisWeek && data.readersThisWeek > 0}
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path d="M10 12a2 2 0 100-4 2 2 0 000 4z"/><path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"/></svg>
{data.readersThisWeek} reading this week
</span>
{/if}
</div>
<!-- Summary with expand toggle -->
@@ -243,7 +249,17 @@
{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}
@@ -294,7 +310,17 @@
{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}

View File

@@ -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
};
};

View File

@@ -1,6 +1,9 @@
<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 CommentsSection from '$lib/components/CommentsSection.svelte';
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
@@ -9,8 +12,80 @@
let html = $state(untrack(() => data.html));
let fetchingContent = $state(untrack(() => !data.isPreview && !data.html));
let fetchError = $state('');
let audioProRequired = $state(false);
onMount(async () => {
// 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') ?? '';
}
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;
}
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,
@@ -20,7 +95,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 })
@@ -30,26 +105,37 @@
}
}
// 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 = m.reader_audio_error();
(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 = m.reader_fetching_chapter();
} finally {
fetchingContent = false;
}
})();
}
return () => {
if (pollingTimer) clearTimeout(pollingTimer);
};
});
const wordCount = $derived(
@@ -77,7 +163,7 @@
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) 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"
>
&larr; {m.reader_chapter_n({ n: String(data.prev) })}
</a>
@@ -85,7 +171,7 @@
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="px-3 py-1.5 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) 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"
>
{m.reader_chapter_n({ n: String(data.next) })} &rarr;
</a>
@@ -94,17 +180,106 @@
</div>
<!-- Chapter heading -->
<div class="mb-6">
<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-(--color-muted) text-xs mt-1">{m.reader_words({ n: wordCount.toLocaleString() })}</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}
@@ -114,7 +289,9 @@
nextChapter={data.next}
chapters={data.chapters}
voices={data.voices}
onProRequired={() => { audioProRequired = true; }}
/>
{/if}
{:else}
<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()}
@@ -145,19 +322,29 @@
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors"
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
>
&larr; {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-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
>
{m.reader_next_chapter()} &rarr;
</a>
{/if}
</div>
<!-- Chapter comments -->
<div class="mt-12">
<CommentsSection
slug={data.book.slug}
chapter={data.chapter.number}
isLoggedIn={!!page.data.user}
currentUserId={page.data.user?.id ?? ''}
/>
</div>

View File

@@ -117,11 +117,15 @@
{ 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: 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))
})))
]);
@@ -238,11 +242,11 @@
<!-- 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">
<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">
<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'}

View File

@@ -34,7 +34,7 @@
href="/auth/google"
class="flex items-center justify-center gap-3 w-full py-3 px-4 rounded-lg
bg-(--color-surface-2) border border-(--color-border) text-(--color-text) text-sm font-medium
hover:bg-(--color-surface-3) hover:border-zinc-600 transition-colors"
hover:bg-(--color-surface-3) hover:border-(--color-brand)/50 transition-colors"
>
<svg class="w-5 h-5 shrink-0" viewBox="0 0 24 24" aria-hidden="true">
<path
@@ -62,7 +62,7 @@
href="/auth/github"
class="flex items-center justify-center gap-3 w-full py-3 px-4 rounded-lg
bg-(--color-surface-2) border border-(--color-border) text-(--color-text) text-sm font-medium
hover:bg-(--color-surface-3) hover:border-zinc-600 transition-colors"
hover:bg-(--color-surface-3) hover:border-(--color-brand)/50 transition-colors"
>
<svg class="w-5 h-5 shrink-0 fill-(--color-text)" viewBox="0 0 24 24" aria-hidden="true">
<path

View File

@@ -1,6 +1,6 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { changePassword, listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
import { resolveAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
@@ -38,40 +38,3 @@ export const load: PageServerLoad = async ({ locals }) => {
}))
};
};
export const actions: Actions = {
changePassword: async ({ request, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Not logged in.' });
}
const data = await request.formData();
const current = (data.get('current') as string | null) ?? '';
const next = (data.get('next') as string | null) ?? '';
const confirm = (data.get('confirm') as string | null) ?? '';
if (!current || !next || !confirm) {
return fail(400, { error: 'All fields are required.' });
}
if (next.length < 8) {
return fail(400, { error: 'New password must be at least 8 characters.' });
}
if (next !== confirm) {
return fail(400, { error: 'New passwords do not match.' });
}
let ok: boolean;
try {
ok = await changePassword(locals.user.id, current, next);
} catch (e) {
log.error('profile', 'changePassword failed', { err: String(e) });
return fail(500, { error: 'An error occurred. Please try again.' });
}
if (!ok) {
return fail(401, { error: 'Current password is incorrect.' });
}
return { success: true };
}
};

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { untrack, getContext } from 'svelte';
import type { PageData, ActionData } from './$types';
import { audioStore } from '$lib/audio.svelte';
@@ -16,14 +15,12 @@
let avatarError = $state('');
let fileInput: HTMLInputElement | null = null;
// Crop modal state
let cropFile = $state<File | null>(null);
function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Reset input so the same file can be re-selected after cancel
if (fileInput) fileInput.value = '';
cropFile = file;
}
@@ -33,7 +30,6 @@
avatarUploading = true;
avatarError = '';
try {
// POST raw bytes to the SvelteKit server, which proxies to MinIO internally.
const res = await fetch('/api/profile/avatar', {
method: 'POST',
headers: { 'Content-Type': mimeType },
@@ -57,87 +53,104 @@
cropFile = null;
}
// ── Settings ────────────────────────────────────────────────────────────────
// ── Voices ───────────────────────────────────────────────────────────────────
let voices = $state<Voice[]>([]);
let voicesLoaded = $state(false);
// Derived: voices grouped by engine
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
// Load voices on mount
$effect(() => {
fetch('/api/voices')
.then((r) => r.json())
.then((d: { voices: Voice[] }) => {
voices = d.voices ?? [];
voicesLoaded = true;
})
.catch(() => {
voicesLoaded = true;
});
.then((d: { voices: Voice[] }) => { voices = d.voices ?? []; voicesLoaded = true; })
.catch(() => { voicesLoaded = true; });
});
// Mirror from audioStore so sliders feel live
// ── Settings state ───────────────────────────────────────────────────────────
let voice = $state(audioStore.voice);
let speed = $state(audioStore.speed);
let autoNext = $state(audioStore.autoNext);
// Keep in sync when layout changes them externally
$effect(() => {
voice = audioStore.voice;
speed = audioStore.speed;
autoNext = audioStore.autoNext;
});
// ── Theme ────────────────────────────────────────────────────────────────────
const themeCtx = getContext<{ currentTheme: string; setTheme: (t: string) => void } | undefined>('theme');
let selectedTheme = $state(untrack(() => data.settings?.theme ?? themeCtx?.currentTheme ?? 'amber'));
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
let selectedTheme = $state(untrack(() => data.settings?.theme ?? settingsCtx?.current ?? 'amber'));
let selectedFontFamily = $state(untrack(() => data.settings?.fontFamily ?? settingsCtx?.fontFamily ?? 'system'));
let selectedFontSize = $state(untrack(() => data.settings?.fontSize ?? settingsCtx?.fontSize ?? 1.0));
const THEMES: { id: string; label: () => string; swatch: string }[] = [
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
{ id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' },
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
const THEMES: { id: string; label: () => string; swatch: string; light?: boolean }[] = [
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
{ id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' },
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
{ id: 'light', label: () => m.profile_theme_light(), swatch: '#d97706', light: true },
{ id: 'light-slate', label: () => m.profile_theme_light_slate(), swatch: '#4f46e5', light: true },
{ id: 'light-rose', label: () => m.profile_theme_light_rose(), swatch: '#e11d48', light: true },
];
let settingsSaving = $state(false);
let settingsSaved = $state(false);
const FONTS = [
{ id: 'system', label: () => m.profile_font_system() },
{ id: 'serif', label: () => m.profile_font_serif() },
{ id: 'mono', label: () => m.profile_font_mono() },
];
async function saveSettings() {
settingsSaving = true;
settingsSaved = false;
try {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed, theme: selectedTheme })
});
// Sync to audioStore so the player picks up changes immediately
audioStore.autoNext = autoNext;
audioStore.voice = voice;
audioStore.speed = speed;
// Apply theme live via context
themeCtx?.setTheme(selectedTheme);
await invalidateAll();
settingsSaved = true;
setTimeout(() => (settingsSaved = false), 2500);
} finally {
settingsSaving = false;
}
}
const FONT_SIZES = [
{ value: 0.9, label: () => m.profile_text_size_sm() },
{ value: 1.0, label: () => m.profile_text_size_md() },
{ value: 1.15, label: () => m.profile_text_size_lg() },
{ value: 1.3, label: () => m.profile_text_size_xl() },
];
// ── Password change ─────────────────────────────────────────────────────────
let pwSubmitting = $state(false);
let pwSuccess = $state(false);
// ── Auto-save ────────────────────────────────────────────────────────────────
type SaveStatus = 'idle' | 'saving' | 'saved';
let saveStatus = $state<SaveStatus>('idle');
let saveTimer = 0;
let savedTimer = 0;
let initialized = false;
$effect(() => {
if (form?.success) {
pwSuccess = true;
setTimeout(() => (pwSuccess = false), 3000);
// Read all settings deps to subscribe
const t = selectedTheme;
const ff = selectedFontFamily;
const fs = selectedFontSize;
const v = voice;
const sp = speed;
const an = autoNext;
// Apply context immediately (font/theme previews live without waiting for save)
if (settingsCtx) {
settingsCtx.current = t;
settingsCtx.fontFamily = ff;
settingsCtx.fontSize = fs;
}
audioStore.voice = v;
audioStore.autoNext = an;
if (!initialized) { initialized = true; return; }
clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
saveStatus = 'saving';
try {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext: an, voice: v, speed: sp, theme: t, fontFamily: ff, fontSize: fs })
});
saveStatus = 'saved';
clearTimeout(savedTimer);
savedTimer = setTimeout(() => (saveStatus = 'idle'), 2000) as unknown as number;
} catch {
saveStatus = 'idle';
}
}, 800) as unknown as number;
});
// ── Sessions ────────────────────────────────────────────────────────────────
// ── Sessions ────────────────────────────────────────────────────────────────
type Session = {
id: string;
user_agent: string;
@@ -156,19 +169,12 @@
revokeError = '';
try {
const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' });
if (!res.ok) {
revokeError = 'Failed to end session. Please try again.';
return;
}
if (!res.ok) { revokeError = 'Failed to end session. Please try again.'; return; }
if (session.is_current) {
// Ended our own session — submit the logout form to clear the cookie
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
if (logoutForm) {
logoutForm.submit();
}
if (logoutForm) logoutForm.submit();
return;
}
// Remove from local list
sessions = sessions.filter((s) => s.id !== session.id);
} catch {
revokeError = 'Network error. Please try again.';
@@ -180,18 +186,12 @@
function formatDate(iso: string): string {
if (!iso) return '—';
try {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(iso));
} catch {
return iso;
}
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
} catch { return iso; }
}
function parseUA(ua: string): string {
if (!ua) return 'Unknown browser';
// Very lightweight UA display — just show the most meaningful part
if (/Mobile/i.test(ua)) {
const match = ua.match(/\(([^)]+)\)/);
return match ? `Mobile — ${match[1].split(';')[0].trim()}` : 'Mobile device';
@@ -210,24 +210,20 @@
{#if cropFile && browser}
{#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }}
<AvatarCropModal
file={cropFile}
onconfirm={handleCropConfirm}
oncancel={handleCropCancel}
/>
<AvatarCropModal file={cropFile} onconfirm={handleCropConfirm} oncancel={handleCropCancel} />
{/await}
{/if}
<!-- Hidden logout form used when user ends their own session -->
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
<div class="max-w-xl mx-auto space-y-10">
<div class="flex items-center gap-5">
<!-- Avatar -->
<div class="max-w-2xl mx-auto space-y-6 pb-12">
<!-- ── Profile header ──────────────────────────────────────────────────────── -->
<div class="flex items-center gap-5 pt-2">
<div class="relative shrink-0">
<button
onclick={() => fileInput?.click()}
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-(--color-border) hover:ring-(--color-brand) transition-all focus:outline-none focus:ring-(--color-brand)"
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-(--color-border) hover:ring-(--color-brand) transition-all focus:outline-none"
title={m.profile_change_avatar()}
disabled={avatarUploading}
>
@@ -240,7 +236,6 @@
</svg>
</div>
{/if}
<!-- Hover overlay -->
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{#if avatarUploading}
<svg class="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24">
@@ -255,34 +250,96 @@
{/if}
</div>
</button>
<input
bind:this={fileInput}
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden"
onchange={handleAvatarChange}
/>
</div>
<input bind:this={fileInput} type="file" accept="image/jpeg,image/png,image/webp" class="hidden" onchange={handleAvatarChange} />
</div>
<div>
<h1 class="text-2xl font-bold text-(--color-text)">{data.user.username}</h1>
<p class="text-(--color-muted) text-sm mt-0.5 capitalize">{data.user.role}</p>
<div class="min-w-0">
<h1 class="text-2xl font-bold text-(--color-text) truncate">{data.user.username}</h1>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<span class="text-xs font-semibold px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) capitalize border border-(--color-border)">{data.user.role}</span>
{#if data.isPro}
<span class="inline-flex items-center gap-1 text-xs font-bold px-2 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 uppercase tracking-wide">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
{m.profile_plan_pro()}
</span>
{/if}
</div>
{#if avatarError}
<p class="text-(--color-danger) text-xs mt-1">{avatarError}</p>
<p class="text-(--color-danger) text-xs mt-1.5">{avatarError}</p>
{:else}
<p class="text-(--color-muted) text-xs mt-1">{m.profile_click_to_change()}</p>
<p class="text-(--color-muted) text-xs mt-1.5">{m.profile_click_to_change()}</p>
{/if}
</div>
</div>
<!-- ── Appearance ────────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_appearance_heading()}</h2>
<!-- ── Subscription ─────────────────────────────────────────────────────────── -->
{#if !data.isPro}
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
<div class="flex items-start justify-between gap-4">
<div class="space-y-1">
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_subscription_heading()}</h2>
<p class="text-sm text-(--color-muted)">{m.profile_free_limits()}</p>
</div>
<span class="shrink-0 inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) uppercase tracking-wide">
{m.profile_plan_free()}
</span>
</div>
<div class="mt-5 pt-5 border-t border-(--color-border)">
<p class="text-sm font-medium text-(--color-text) mb-1">{m.profile_upgrade_heading()}</p>
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
<div class="flex flex-wrap gap-3">
<a href="https://buy.polar.sh/libnovel/1376fdf5-b6a9-492b-be70-7c905131c0f9" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
{m.profile_upgrade_monthly()}
</a>
<a href="https://buy.polar.sh/libnovel/b6190307-79aa-4905-80c8-9ed941378d21" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors">
{m.profile_upgrade_annual()}
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">33%</span>
</a>
</div>
</div>
</section>
{:else}
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5 flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-(--color-text)">{m.profile_pro_active()}</p>
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
</div>
<a href="https://polar.sh/libnovel" target="_blank" rel="noopener noreferrer"
class="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline">
{m.profile_manage_subscription()}
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</section>
{/if}
<div class="space-y-2">
<!-- ── Preferences ──────────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) divide-y divide-(--color-border)">
<!-- Section header with auto-save indicator -->
<div class="flex items-center justify-between px-6 py-4">
<h2 class="text-base font-semibold text-(--color-text)">Preferences</h2>
<span class="text-xs transition-all duration-300 {saveStatus === 'saving' ? 'text-(--color-muted)' : saveStatus === 'saved' ? 'text-(--color-success)' : 'opacity-0 pointer-events-none'}">
{#if saveStatus === 'saving'}
{m.profile_saving()}
{:else if saveStatus === 'saved'}
{m.profile_saved()}
{:else}
{m.profile_saved()}
{/if}
</span>
</div>
<!-- Theme -->
<div class="px-6 py-5 space-y-3">
<p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
<div class="flex gap-3 flex-wrap">
{#each THEMES as t}
<div class="flex gap-2 flex-wrap items-center">
{#each THEMES as t, i}
{#if i === 3}
<span class="w-px h-6 bg-(--color-border) mx-1 self-center"></span>
{/if}
<button
type="button"
onclick={() => (selectedTheme = t.id)}
@@ -292,111 +349,120 @@
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={selectedTheme === t.id}
>
<span class="w-3.5 h-3.5 rounded-full flex-shrink-0" style="background: {t.swatch};"></span>
<span class="w-3 h-3 rounded-full shrink-0 {t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
{t.label()}
{#if selectedTheme === t.id}
<svg class="w-3 h-3 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>
{/if}
</button>
{/each}
</div>
</div>
</section>
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_reading_heading()}</h2>
<!-- Font family -->
<div class="px-6 py-5 space-y-3">
<p class="text-sm font-medium text-(--color-text)">{m.profile_font_family()}</p>
<div class="flex gap-2 flex-wrap">
{#each FONTS as f}
<button
type="button"
onclick={() => (selectedFontFamily = f.id)}
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
{selectedFontFamily === f.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={selectedFontFamily === f.id}
>
{f.label()}
</button>
{/each}
</div>
</div>
<!-- Voice -->
<div class="space-y-1.5">
<!-- Text size -->
<div class="px-6 py-5 space-y-3">
<p class="text-sm font-medium text-(--color-text)">{m.profile_text_size()}</p>
<div class="flex gap-2 flex-wrap">
{#each FONT_SIZES as s}
<button
type="button"
onclick={() => (selectedFontSize = s.value)}
class="px-3 py-2 rounded-lg border text-sm font-medium transition-colors
{selectedFontSize === s.value
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={selectedFontSize === s.value}
>
{s.label()}
</button>
{/each}
</div>
</div>
<!-- TTS voice -->
<div class="px-6 py-5 space-y-3">
<label class="block text-sm font-medium text-(--color-text)" for="voice-select">{m.profile_tts_voice()}</label>
{#if !voicesLoaded}
<div class="h-9 bg-(--color-surface-3) rounded animate-pulse"></div>
<div class="h-9 bg-(--color-surface-3) rounded-lg animate-pulse"></div>
{:else if voices.length === 0}
<select id="voice-select" disabled class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-muted) text-sm cursor-not-allowed">
<option>{m.common_loading()}</option>
</select>
{:else}
<select
id="voice-select"
bind:value={voice}
class="w-full bg-(--color-surface-3) 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)"
>
<select id="voice-select" bind:value={voice}
class="w-full bg-(--color-surface-3) 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)">
{#if kokoroVoices.length > 0}
<optgroup label="Kokoro (GPU)">
{#each kokoroVoices as v}
<option value={v.id}>{v.id}</option>
{/each}
{#each kokoroVoices as v}<option value={v.id}>{v.id}</option>{/each}
</optgroup>
{/if}
{#if pocketVoices.length > 0}
<optgroup label="Pocket TTS (CPU)">
{#each pocketVoices as v}
<option value={v.id}>{v.id}</option>
{/each}
{#each pocketVoices as v}<option value={v.id}>{v.id}</option>{/each}
</optgroup>
{/if}
</select>
{/if}
</div>
<!-- Speed -->
<div class="space-y-1.5">
<label class="block text-sm font-medium text-(--color-text)" for="speed-range">
{m.profile_playback_speed({ speed: speed.toFixed(1) })}
</label>
<input
id="speed-range"
type="range"
min="0.5"
max="3.0"
step="0.1"
bind:value={speed}
style="accent-color: var(--color-brand);"
class="w-full"
/>
<!-- Playback speed -->
<div class="px-6 py-5 space-y-3">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-(--color-text)" for="speed-range">{m.profile_playback_speed({ speed: '' })}</label>
<span class="text-sm font-mono text-(--color-brand)">{speed.toFixed(1)}x</span>
</div>
<input id="speed-range" type="range" min="0.5" max="3.0" step="0.1" bind:value={speed}
style="accent-color: var(--color-brand);" class="w-full" />
<div class="flex justify-between text-xs text-(--color-muted)">
<span>0.5x</span>
<span>3.0x</span>
<span>0.5x</span><span>3.0x</span>
</div>
</div>
<!-- Auto-next -->
<label class="flex items-center gap-3 cursor-pointer select-none">
<input
type="checkbox"
bind:checked={autoNext}
style="accent-color: var(--color-brand);"
class="w-4 h-4 rounded"
/>
<span class="text-sm text-(--color-text)">{m.profile_auto_advance()}</span>
</label>
<div class="flex items-center gap-3 pt-1">
<!-- Auto-advance -->
<div class="px-6 py-5 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-(--color-text)">{m.profile_auto_advance()}</p>
<p class="text-xs text-(--color-muted) mt-0.5">Automatically load the next chapter when audio finishes</p>
</div>
<button
onclick={saveSettings}
disabled={settingsSaving}
class="px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60"
type="button"
role="switch"
aria-checked={autoNext}
onclick={() => (autoNext = !autoNext)}
class="shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-(--color-brand) focus:ring-offset-2 focus:ring-offset-(--color-surface) {autoNext ? 'bg-(--color-brand)' : 'bg-(--color-surface-3) border border-(--color-border)'}"
>
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
<span class="inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform {autoNext ? 'translate-x-6' : 'translate-x-1'}"></span>
</button>
{#if settingsSaved}
<span class="text-sm text-green-400">{m.profile_saved()}</span>
{/if}
</div>
</section>
<!-- ── Active sessions ──────────────────────────────────────────────────── -->
<!-- ── Active sessions ──────────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2>
<p class="text-sm text-(--color-muted)">{m.profile_session_unrecognised()}</p>
<div>
<h2 class="text-base font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2>
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_session_unrecognised()}</p>
</div>
{#if revokeError}
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">
{revokeError}
</div>
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">{revokeError}</div>
{/if}
{#if sessions.length === 0}
@@ -428,7 +494,7 @@
class="shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
{session.is_current
? 'bg-(--color-danger)/10 text-(--color-danger) border border-(--color-danger)/60 hover:bg-(--color-danger)/20'
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-3)'}"
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-2)'}"
>
{revokingId === session.id ? '…' : session.is_current ? m.profile_session_sign_out() : m.profile_session_end()}
</button>
@@ -438,74 +504,4 @@
{/if}
</section>
<!-- ── Change password ──────────────────────────────────────────────────── -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_change_password_heading()}</h2>
{#if form?.error}
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">
{form.error}
</div>
{/if}
{#if pwSuccess}
<div class="rounded-lg bg-green-900/40 border border-green-700 px-4 py-2.5 text-sm text-green-300">
{m.profile_password_changed_ok()}
</div>
{/if}
<form
method="POST"
action="?/changePassword"
use:enhance={() => {
pwSubmitting = true;
return async ({ update }) => {
pwSubmitting = false;
await update();
};
}}
class="space-y-4"
>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-(--color-text)" for="current">{m.profile_current_password()}</label>
<input
id="current"
name="current"
type="password"
autocomplete="current-password"
required
class="w-full bg-(--color-surface-3) 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>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-(--color-text)" for="next">{m.profile_new_password()}</label>
<input
id="next"
name="next"
type="password"
autocomplete="new-password"
required
class="w-full bg-(--color-surface-3) 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>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-(--color-text)" for="confirm">{m.profile_confirm_password()}</label>
<input
id="confirm"
name="confirm"
type="password"
autocomplete="new-password"
required
class="w-full bg-(--color-surface-3) 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>
<button
type="submit"
disabled={pwSubmitting}
class="px-4 py-2 rounded-lg bg-(--color-surface-3) text-(--color-text) font-semibold text-sm hover:bg-(--color-surface-3) transition-colors disabled:opacity-60"
>
{pwSubmitting ? m.profile_updating() : m.profile_update_password()}
</button>
</form>
</section>
</div>

View File

@@ -0,0 +1,37 @@
import type { RequestHandler } from './$types';
const SITE = 'https://libnovel.cc';
// Only static public pages are listed here. Book/catalogue pages are
// discoverable via the catalogue link and don't need individual entries
// (the catalogue itself serves as an index for crawlers).
const PUBLIC_PAGES = [
{ path: '/catalogue', changefreq: 'daily', priority: '0.9' },
{ path: '/login', changefreq: 'monthly', priority: '0.5' },
{ path: '/disclaimer', changefreq: 'yearly', priority: '0.2' },
{ path: '/privacy', changefreq: 'yearly', priority: '0.2' },
{ path: '/dmca', changefreq: 'yearly', priority: '0.2' },
];
export const GET: RequestHandler = () => {
const urls = PUBLIC_PAGES.map(
({ path, changefreq, priority }) => `
<url>
<loc>${SITE}${path}</loc>
<changefreq>${changefreq}</changefreq>
<priority>${priority}</priority>
</url>`
).join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`;
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=86400'
}
});
};

View File

@@ -1,3 +1,14 @@
# allow crawling everything by default
User-agent: *
Disallow:
Disallow: /books
Disallow: /profile
Disallow: /admin/
Disallow: /api/
Disallow: /auth/
Allow: /books/
Allow: /catalogue
Allow: /login
Allow: /disclaimer
Allow: /privacy
Allow: /dmca
Sitemap: https://libnovel.cc/sitemap.xml