Compare commits

...

35 Commits

Author SHA1 Message Date
Admin
4d3b91af30 feat: add Grafana Faro RUM, fix dashboards, add Grafana to admin nav
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 1m5s
Release / Docker / caddy (push) Successful in 1m9s
Release / Docker / backend (push) Successful in 3m14s
Release / Docker / runner (push) Successful in 4m7s
Release / Upload source maps (push) Successful in 2m11s
Release / Docker / ui (push) Successful in 2m23s
Release / Gitea Release (push) Successful in 40s
- Add @grafana/faro-web-sdk to UI; wire initializeFaro in hooks.client.ts
  gated on PUBLIC_FARO_COLLECTOR_URL (no-op in dev)
- Add Grafana Alloy service (faro.receiver) to homelab compose;
  Faro endpoint → alloy:12347 (faro.libnovel.cc via cloudflared)
- Add PUBLIC_FARO_COLLECTOR_URL env var to docker-compose.yml UI service
- Add Web Vitals dashboard (web-vitals.json): LCP/INP/CLS/TTFB/FCP p75
  stats + LCP/TTFB time-series + Faro exception logs from Loki
- Fix runner.json: strip libnovel_ prefix from all metric names
- Fix backend.json: replace 5 dead http_client_* panels with
  spanmetrics-based equivalents (Request Rate by Span Name + Latency
  by Span Name p95)
- Fix OTel collector: add service.telemetry.metrics.address: 0.0.0.0:8888
  so Prometheus can scrape collector self-metrics
- Add Grafana link to admin nav external tools; add admin_nav_grafana
  message key to all 5 locale files; recompile paraglide
2026-04-05 12:58:16 +05:00
Admin
eb8a92f0c1 ci: fix GlitchTip project slug (ui, not libnovel-ui)
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 5m16s
Release / Docker / runner (push) Successful in 4m32s
Release / Upload source maps (push) Successful in 2m8s
Release / Docker / ui (push) Successful in 2m45s
Release / Gitea Release (push) Successful in 59s
2026-04-05 12:27:02 +05:00
Admin
fa2803c164 ci: re-enable GlitchTip source map upload job for UI releases
Some checks failed
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 56s
Release / Docker / runner (push) Has been cancelled
Release / Upload source maps (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Docker / backend (push) Has been cancelled
2026-04-05 12:26:23 +05:00
Admin
787942b172 fix: split GLITCHTIP_DSN into per-service vars (backend/runner/ui) 2026-04-05 12:17:03 +05:00
Admin
cb858bf4c9 chor: delete all ios-ux skill 2026-04-05 11:51:23 +05:00
Admin
4c3c160102 fix: downscale reference image before CF AI img2img to avoid 502 payload limit
CF Workers AI has a ~4MB JSON body limit. Large cover images base64-encoded
can easily exceed this, causing Cloudflare to return a 502 Bad Gateway.

Added resizeRefImage() which scales the reference down so its longest side
≤ 768px (nearest-neighbour, re-encoded as JPEG) before passing it to the
GenerateImageFromReference call. Images already within the limit pass through
unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 11:40:37 +05:00
Admin
37deac1eb3 fix: resolve all svelte-check warnings and errors (0 errors, 0 warnings)
All checks were successful
Release / Test backend (push) Successful in 45s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 40s
Release / Docker / backend (push) Successful in 4m54s
Release / Docker / runner (push) Successful in 56s
Release / Docker / ui (push) Successful in 2m9s
Release / Gitea Release (push) Successful in 48s
2026-04-05 11:03:23 +05:00
Admin
6f0069daca feat: catalogue enrichment — tagline, genres, warnings, quality score, batch covers
Backend (handlers_catalogue.go):
- POST /api/admin/text-gen/tagline — 1-sentence marketing hook
- POST /api/admin/text-gen/genres + /apply — LLM genre suggestions, editable + persist
- POST /api/admin/text-gen/content-warnings — mature theme detection
- POST /api/admin/text-gen/quality-score — 1–5 description quality rating
- POST /api/admin/catalogue/batch-covers (SSE) — generate covers for books missing one
- POST /api/admin/catalogue/batch-covers/cancel — cancel via in-memory job registry
- POST /api/admin/catalogue/refresh-metadata/{slug} (SSE) — description + cover refresh

Frontend:
- text-gen: 4 new tabs (Tagline, Genres, Warnings, Quality) with book autocomplete
- image-gen: localStorage style presets (save/apply/delete named prompt templates)
- catalogue-tools: new admin page with batch cover SSE progress + cancel
- admin nav: "Catalogue Tools" link added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:52:38 +05:00
Admin
0fc30d1328 fix: handle Llama 4 Scout array response shape in CF AI text decoder
Llama 4 Scout returns `result.response` as an array of objects
[{"generated_text":"..."}] instead of a plain string. Decode into
json.RawMessage and try both shapes; fall back to generated_text[0].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:28:14 +05:00
Admin
40151f2f33 feat: enhance admin panel on book page — prompts, img2img, SSE chapter names
Some checks failed
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Failing after 35s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 31s
Release / Docker / backend (push) Successful in 2m21s
Release / Docker / runner (push) Successful in 2m23s
Release / Gitea Release (push) Has been skipped
- Cover generation: editable prompt (pre-filled from title+summary),
  img2img toggle to use existing cover as reference, Full editor link
- Chapter cover: editable prompt textarea
- Description: instructions input field, Full editor link
- Chapter names: editable pattern field, SSE streaming with live batch
  progress, inline-editable title proposals, batch warning display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 00:45:12 +05:00
Admin
ad2d1a2603 feat: stream chapter-name generation via SSE batching
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 3m0s
Release / Docker / runner (push) Successful in 2m38s
Release / Docker / ui (push) Successful in 2m16s
Release / Gitea Release (push) Successful in 36s
Split chapter-name LLM requests into 100-chapter batches and stream
results back as SSE so large books (e.g. Shadow Slave: 2916 chapters)
never time out or truncate. Frontend shows live batch progress inline
and accumulates proposals as they arrive.
2026-04-05 00:32:18 +05:00
Admin
b0d8c02787 fix: add created field to chapters_idx to fix recentlyUpdatedBooks 400
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 40s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 2m43s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Successful in 50s
chapters_idx was missing created/updated columns (never defined in the
PocketBase schema), causing PocketBase to return 400 for any query
sorted by -created. recentlyUpdatedBooks() uses this sort.

- Add created date field to chapters_idx schema in pb-init-v3.sh
  (also added via add_field for existing installations)
- Add idx_chapters_idx_created index for sort performance
- Set created timestamp on first insert in upsertChapterIdx so new
  chapters are immediately sortable; existing records retain empty created
  and will sort to the back (acceptable — only affects home page recency)
2026-04-04 23:47:23 +05:00
Admin
5b4c1db931 fix: add watchtower label to runner service so auto-updates work
Without com.centurylinklabs.watchtower.enable=true the homelab watchtower
(running with --label-enable) silently skipped the runner container,
leaving it stuck on v2.5.60 while fixes accumulated on newer tags.
2026-04-04 23:39:19 +05:00
Admin
0c54c59586 fix: guard against font_size=0 collapsing chapter text
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / backend (push) Successful in 2m30s
Release / Docker / runner (push) Successful in 2m38s
Release / Docker / ui (push) Successful in 2m1s
Release / Gitea Release (push) Successful in 41s
- Replace ?? with || when reading font_size so 0 falls back to 1.0
  (affects GET /api/settings, layout.server.ts, +layout.svelte)
- Remove the explicit 'body.fontSize !== 0' exception in PUT /api/settings
  validation so 0 is now correctly rejected as an invalid font size
- Add add_index helper + idx_chapters_idx_slug_number declaration to
  scripts/pb-init-v3.sh (idempotent UNIQUE INDEX on chapters_idx)
2026-04-04 23:26:54 +05:00
Admin
0e5eb84097 feat: add SvelteKit proxy route for admin dedup-chapters endpoint
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 6m12s
Release / Docker / runner (push) Successful in 3m8s
Release / Docker / ui (push) Successful in 2m15s
Release / Gitea Release (push) Successful in 47s
2026-04-04 22:34:26 +05:00
Admin
6ef82a1d12 fix: add DeduplicateChapters stub to test mocks to satisfy BookWriter interface
All checks were successful
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 3m19s
Release / Docker / ui (push) Successful in 3m12s
Release / Gitea Release (push) Successful in 1m22s
2026-04-04 21:17:55 +05:00
Admin
7a418ee62b fix: await marked() to prevent Promise being passed as chapter HTML
Some checks failed
Release / Test backend (push) Failing after 15s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 39s
Release / Docker / ui (push) Successful in 2m41s
Release / Gitea Release (push) Has been skipped
marked() returns string | Promise<string>; the previous cast 'as string'
silently passed a Promise object, which Svelte rendered as nothing.
Free users saw blank content even though SSR HTML was correct.
2026-04-04 21:15:06 +05:00
Admin
d4f35a4899 fix: prevent duplicate chapters_idx records + add dedup endpoint
Some checks failed
Release / Test backend (push) Failing after 18s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 38s
Release / Docker / ui (push) Successful in 2m45s
Release / Gitea Release (push) Has been skipped
- Fix upsertChapterIdx race: use conflict-retry pattern (mirrors WriteMetadata)
  so concurrent goroutines don't double-POST the same chapter number
- Add DeduplicateChapters to BookWriter interface and Store implementation;
  keeps the latest record per (slug, number) and deletes extras
- Wire POST /api/admin/dedup-chapters/{slug} handler in server.go
2026-04-04 21:00:10 +05:00
Admin
6559a8c015 fix: split long text into chunks before sending to Cloudflare AI TTS
Some checks failed
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m5s
Release / Docker / caddy (push) Successful in 37s
Release / Docker / backend (push) Has been cancelled
Release / Docker / runner (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
The aura-2-en model enforces a hard 2 000-character limit per request.
Chapters routinely exceed this, producing 413 errors.

GenerateAudio now splits the stripped text into ≤1 800-char chunks at
paragraph → sentence → space → hard-cut boundaries, calls the API once
per chunk, and concatenates the MP3 frames. Callers (runner, streaming
handler) are unchanged. StreamAudioMP3/WAV inherit the fix automatically
since they delegate to GenerateAudio.
2026-04-04 20:45:22 +05:00
Admin
05bfd110b8 refactor: replace floating settings panel with bottom sheet + Reading/Listening tabs
Some checks failed
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 39s
Release / Docker / runner (push) Has been cancelled
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Docker / backend (push) Has been cancelled
The old vertical dropdown was too tall on mobile — 14 stacked groups
required heavy scrolling and had no visual hierarchy.

New design:
- Full-width bottom sheet slides from screen edge (natural mobile gesture)
- Drag handle + dimmed backdrop for clarity
- Two tabs split the settings: Reading (typography + layout) and Listening
  (player style, speed, auto-next, sleep timer)
- Each row uses label-on-left + pill-group-on-right layout — saves one line
  per setting and makes the list scannable at a glance
- Settings are grouped into titled cards (Typography, Layout, Player)
  with dividers between rows instead of floating individual blocks
- Gear button moved to bottom-[4.5rem] to clear the mini-player bar
2026-04-04 20:35:15 +05:00
Admin
bfd0ad8fb7 fix: chapter content vanishes — replace stale untrack snapshots with $derived
Some checks failed
Release / Test backend (push) Successful in 44s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 2m58s
Release / Docker / ui (push) Successful in 4m58s
Release / Gitea Release (push) Has been cancelled
html and fetchingContent were captured with untrack() at mount time.
When SvelteKit re-ran the page load (triggered by the layout's settings PUT),
data.html updated but html stayed stale. The {#key} block in the layout then
destroyed and recreated the component, and on remount data.html was momentarily
empty so html became '' and the live-scrape fallback ran unnecessarily.

Fix:
- html is now $derived(scrapedHtml || data.html || '') — always tracks load
- scrapedHtml is a separate $state only set by the live-scrape fallback
- fetchingContent starts false; the fallback sets it true only when actually fetching
- translationStatus/translatingLang: dropped untrack() so they also react to re-runs
- Removed unused untrack import
2026-04-04 20:26:43 +05:00
Admin
4b7fcf432b fix: pass POLAR_API_TOKEN and POLAR_WEBHOOK_SECRET to ui container
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 47s
Release / Docker / caddy (push) Successful in 45s
Release / Docker / backend (push) Successful in 2m35s
Release / Docker / runner (push) Successful in 2m36s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Successful in 43s
Both vars were present in Doppler but never injected into the ui service
environment, causing all checkout requests to fail with 500 and webhooks
to be silently rejected.
2026-04-04 20:18:19 +05:00
Admin
c4a0256f6e feat: /subscribe pricing page + Pro nav link + fix checkout token scope
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 2m35s
Release / Docker / runner (push) Successful in 2m38s
Release / Docker / ui (push) Successful in 2m12s
Release / Gitea Release (push) Successful in 41s
- Add /subscribe route with hero, benefits list, and pricing cards
  (annual featured with Save 33% badge, monthly secondary)
- Add 'Pro' link in nav for non-Pro users
- Add 'See plans' link in profile subscription section
- i18n keys across en/fr/id/pt/ru for all subscribe strings

Note: checkout still requires POLAR_API_TOKEN with checkouts:write scope.
Regenerate the token at polar.sh to fix the 502 error on subscribe buttons.
2026-04-04 20:12:24 +05:00
Admin
18f490f790 feat: admin controls on book detail page (cover/desc/chapter-names/audio TTS)
All checks were successful
Release / Test backend (push) Successful in 48s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 3m22s
Release / Docker / runner (push) Successful in 2m51s
Release / Docker / ui (push) Successful in 3m17s
Release / Gitea Release (push) Successful in 1m12s
Add 5 admin sections to /books/[slug] for admins:
- Book cover generation (CF AI image-gen with preview + save)
- Chapter cover generation (chapter number input + preview)
- Description regeneration (preview + apply/discard)
- Chapter names generation (preview table + apply/discard)
- Audio TTS bulk enqueue (voice selector, chapter range, cancel)

Also adds /api/admin/audio/bulk and /api/admin/audio/cancel-bulk proxy routes,
and all i18n keys across en/fr/id/pt/ru.
2026-04-04 19:57:16 +05:00
Admin
6456e8cf5d perf: skip ffmpeg transcode for PocketTTS streaming — use WAV directly
All checks were successful
Release / Test backend (push) Successful in 42s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m46s
Release / Docker / runner (push) Successful in 3m12s
Release / Docker / ui (push) Successful in 2m19s
Release / Gitea Release (push) Successful in 36s
PocketTTS emits 16-bit PCM WAV (16 kHz mono). WAV is natively supported
on all browsers including iOS/macOS Safari, so the ffmpeg MP3 transcode
is unnecessary for the streaming path.

Using format=wav for PocketTTS voices eliminates the ffmpeg subprocess
startup delay (~200–400 ms) and a pipeline stage, giving lower latency
to first audio frame. Kokoro and CF AI continue using MP3 (they output
MP3 natively or via the OpenAI-compatible endpoint).

The runner (MinIO storage) is unaffected — it still stores MP3 for
space efficiency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 19:55:05 +05:00
Admin
25150c2284 feat: TTS streaming — Kokoro/PocketTTS audio starts immediately
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / backend (push) Successful in 4m38s
Release / Docker / runner (push) Successful in 2m49s
Release / Docker / ui (push) Successful in 2m13s
Release / Gitea Release (push) Successful in 57s
Previously all voices waited for full TTS generation before first byte
reached the browser (POST → poll → presign flow). For long chapters this
meant 2–5 minutes of silence.

New flow for Kokoro and PocketTTS:
- tryPresign fast path unchanged (audio in MinIO → seekable presigned URL)
- On cache miss: set audioEl.src to /api/audio-stream/{slug}/{n} and mark
  status=ready immediately — audio starts playing within seconds
- Backend streams bytes to browser while concurrently uploading to MinIO;
  subsequent plays use the fast path

New SvelteKit route: GET /api/audio-stream/[slug]/[n]
- Proxies backend handleAudioStream (already implemented in Go)
- Same 3 chapters/day free paywall as POST route
- HEAD handler for paywall pre-check (no side effects, no counter increment)
  so AudioPlayer can surface upgrade CTA before pointing <audio> at URL

CF AI voices keep the old POST+poll flow (batch-only API, no real streaming
benefit, preserves the generating progress bar UX).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 19:49:53 +05:00
Admin
0e0a70a786 feat: listening settings panel + compact player style
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / backend (push) Successful in 3m16s
Release / Docker / runner (push) Successful in 2m53s
Release / Docker / ui (push) Successful in 2m30s
Release / Gitea Release (push) Successful in 49s
- AudioPlayer: add 'compact' playerStyle prop — slim seekable player with
  progress bar, skip ±15/30s, play/pause circle button, and speed cycle
- Chapter reader settings gear panel: new Listening section with player
  style picker (Standard/Compact), speed control (0.75–2×), auto-next
  toggle, and sleep timer — all persisted in reader_layout_v1 localStorage
- audioStore.speed/autoNext/sleep now accessible directly from settings
  panel without opening the audio player

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:34:41 +05:00
Admin
bdbe48ce1a fix: register MediaSession action handlers for iOS lock screen resume
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 56s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 3m13s
Release / Docker / ui (push) Successful in 2m25s
Release / Gitea Release (push) Successful in 50s
Without explicit setActionHandler('play'/'pause'/...) the browser uses
default handling which on iOS Safari stops working after ~1 min of
pause in background/locked state (AudioSession gets suspended and the
lock screen button can't resume it without a direct user gesture).

Registering handlers in the layout (where audioEl lives) ensures:
- play/pause call audioEl directly — iOS treats this as a trusted
  gesture and allows .play() even from the lock screen
- seekbackward/seekforward map to ±15s / ±30s
- playbackState stays in sync so the lock screen shows the right icon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:22:09 +05:00
Admin
87c541b178 fix: text-gen chapter-names truncation and bad title format
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 51s
Release / Docker / backend (push) Successful in 2m45s
Release / Docker / runner (push) Successful in 2m50s
Release / Docker / ui (push) Successful in 2m14s
Release / Gitea Release (push) Successful in 44s
- Default max_tokens to 4096 for chapter-names so large chapter lists
  are not cut off mid-JSON by the model's token limit
- Rewrite system prompt to clarify placeholder semantics ({n} = number,
  {scene} = scene hint) and explicitly forbid echoing the number inside
  the title field — prevents "Chapter 1 - 1: ..." style duplications
- UI: surface raw_response in the error area when chapters:[] is returned
  so the admin can see what the model actually produced
2026-04-04 13:43:29 +05:00
Admin
0b82d96798 feat: admin Text Gen tool — chapter names + book description via CF Workers AI
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 2m55s
Release / Docker / runner (push) Successful in 3m3s
Release / Docker / ui (push) Successful in 2m31s
Release / Gitea Release (push) Successful in 43s
Adds backend handlers and SvelteKit UI for an admin text generation tool.
The tool lets admins propose and apply AI-generated chapter titles and book
descriptions using Cloudflare Workers AI (12 LLM models, model selector shared
across both tabs).
2026-04-04 13:16:10 +05:00
Admin
a2dd0681d2 fix: pass CFAI_ACCOUNT_ID/CFAI_API_TOKEN into backend and runner containers
All checks were successful
Release / Test backend (push) Successful in 54s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m35s
Release / Docker / runner (push) Successful in 2m48s
Release / Docker / ui (push) Successful in 2m45s
Release / Gitea Release (push) Successful in 55s
Both vars were in Doppler but never forwarded via docker-compose.yml, causing
the image generation handler to return 503 "CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing".
2026-04-04 12:50:19 +05:00
Admin
ad50bd21ea chore: add .githooks/pre-commit to auto-recompile paraglide on JSON changes
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m47s
Release / Docker / runner (push) Successful in 2m41s
Release / Docker / ui (push) Successful in 2m7s
Release / Gitea Release (push) Successful in 39s
Committed hooks directory (.githooks/pre-commit) auto-runs `npm run paraglide`
and force-stages the generated JS output whenever ui/messages/*.json files are
staged, preventing svelte-check CI failures from stale paraglide output.

Added `just setup` recipe to configure core.hooksPath for new contributors.
2026-04-04 12:11:42 +05:00
Admin
6572e7c849 fix: cover_url bug, add save-cover endpoint, fix svelte-check CI failure
All checks were successful
Release / Test backend (push) Successful in 40s
Release / Check ui (push) Successful in 44s
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 2m50s
Release / Docker / runner (push) Successful in 2m42s
Release / Docker / ui (push) Successful in 2m9s
Release / Gitea Release (push) Successful in 38s
- Fix handlers_image.go: cover_url now uses /api/cover/novelfire.net/{slug} (was 'local')
- Add POST /api/admin/image-gen/save-cover: persists pre-generated base64 directly to
  MinIO via PutCover without re-calling Cloudflare AI
- Add SvelteKit proxy route api/admin/image-gen/save-cover/+server.ts
- Update UI saveAsCover(): send existing image_b64 to save-cover instead of re-generating
- Run npm run paraglide to compile admin_nav_image_gen message; force-add gitignored files
- svelte-check: 0 errors, 21 warnings (all pre-existing)
2026-04-04 12:05:19 +05:00
Admin
74ece7e94e feat: reading view modes — progress bar, paginated, spacing, width, focus
Some checks failed
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Failing after 27s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 43s
Release / Docker / backend (push) Successful in 2m50s
Release / Docker / runner (push) Successful in 3m3s
Release / Gitea Release (push) Has been skipped
Five new reader settings (persisted in localStorage as reader_layout_v1):

- Read mode: Scroll (default) vs Pages — paginated mode splits content
  into viewport-height pages, navigate with tap left/right, arrow keys,
  or Prev/Next buttons. Recalculates on content change.

- Line spacing: Tight (1.55) / Normal (1.85) / Loose (2.2) via
  --reading-line-height CSS var on :root.

- Reading width: Narrow (58ch) / Normal (72ch) / Wide (90ch) via
  --reading-max-width CSS var on :root.

- Paragraph style: Spaced (default) vs Indented (text-indent: 2em,
  tight margin — book-like feel).

- Focus mode: hides audio player, language switcher, bottom nav and
  comments so only the text remains.

Scroll progress bar: thin 2px brand-colored bar fixed at top of
viewport in scroll mode, fills as you read through the chapter.

All options added to the floating settings gear panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:54:44 +05:00
Admin
d1b7d3e36c feat: admin image generation via Cloudflare Workers AI
Some checks failed
Release / Test backend (push) Successful in 46s
Release / Check ui (push) Failing after 27s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 3m3s
Release / Docker / runner (push) Successful in 2m57s
Release / Gitea Release (push) Has been skipped
- Add cfai/image.go: ImageGenClient with GenerateImage, GenerateImageFromReference, AllImageModels (9 models)
- Add handlers_image.go: GET /api/admin/image-gen/models + POST /api/admin/image-gen (JSON + multipart)
- Wire ImageGen client in main.go + server.go Dependencies
- Add admin/image-gen SvelteKit page: type toggle, model selector, prompt, reference img2img, advanced options, result panel, history, save-as-cover, download
- Add SvelteKit proxy route api/admin/image-gen forwarding to Go backend
- Add admin_nav_image_gen message key to all 5 locale files
- Add Image Gen nav link to admin layout
2026-04-04 11:46:22 +05:00
118 changed files with 9993 additions and 444 deletions

View File

@@ -136,57 +136,55 @@ jobs:
cache-to: type=inline
# ── ui: source map upload ─────────────────────────────────────────────────────
# Commented out: GlitchTip project/auth token needs to be recreated after
# the GlitchTip DB wipe. Re-enable once GLITCHTIP_AUTH_TOKEN is updated.
# upload-sourcemaps:
# name: Upload source maps
# runs-on: ubuntu-latest
# needs: [check-ui]
# defaults:
# run:
# working-directory: ui
# steps:
# - uses: actions/checkout@v4
#
# - uses: actions/setup-node@v4
# with:
# node-version: "22"
# cache: npm
# cache-dependency-path: ui/package-lock.json
#
# - name: Install dependencies
# run: npm ci
#
# - name: Build with source maps
# run: npm run build
#
# - name: Download glitchtip-cli
# run: |
# curl -L "https://gitlab.com/glitchtip/glitchtip-cli/-/jobs/artifacts/v0.1.0/raw/artifacts/glitchtip-cli-linux-x86_64?job=build-linux-x86_64" \
# -o /usr/local/bin/glitchtip-cli
# chmod +x /usr/local/bin/glitchtip-cli
#
# - name: Inject debug IDs into build artifacts
# run: glitchtip-cli sourcemaps inject ./build
# env:
# SENTRY_URL: https://errors.libnovel.cc/
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: libnovel-ui
#
# - name: Upload source maps to GlitchTip
# run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }}
# env:
# SENTRY_URL: https://errors.libnovel.cc/
# SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
# SENTRY_ORG: libnovel
# SENTRY_PROJECT: libnovel-ui
upload-sourcemaps:
name: Upload source maps
runs-on: ubuntu-latest
needs: [check-ui]
defaults:
run:
working-directory: ui
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build with source maps
run: npm run build
- name: Download glitchtip-cli
run: |
curl -L "https://gitlab.com/glitchtip/glitchtip-cli/-/jobs/artifacts/v0.1.0/raw/artifacts/glitchtip-cli-linux-x86_64?job=build-linux-x86_64" \
-o /usr/local/bin/glitchtip-cli
chmod +x /usr/local/bin/glitchtip-cli
- name: Inject debug IDs into build artifacts
run: glitchtip-cli sourcemaps inject ./build
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: ui
- name: Upload source maps to GlitchTip
run: glitchtip-cli sourcemaps upload ./build --release ${{ gitea.ref_name }}
env:
SENTRY_URL: https://errors.libnovel.cc/
SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }}
SENTRY_ORG: libnovel
SENTRY_PROJECT: ui
# ── docker: ui ────────────────────────────────────────────────────────────────
docker-ui:
name: Docker / ui
runs-on: ubuntu-latest
needs: [check-ui]
needs: [check-ui, upload-sourcemaps]
steps:
- uses: actions/checkout@v4
@@ -261,7 +259,7 @@ jobs:
release:
name: Gitea Release
runs-on: ubuntu-latest
needs: [docker-backend, docker-runner, docker-ui, docker-caddy]
needs: [docker-backend, docker-runner, docker-ui, docker-caddy, upload-sourcemaps]
steps:
- uses: actions/checkout@v4
with:

14
.githooks/pre-commit Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Auto-recompile paraglide messages when ui/messages/*.json files are staged.
# Prevents svelte-check / CI failures caused by stale generated JS files.
set -euo pipefail
STAGED=$(git diff --cached --name-only)
if echo "$STAGED" | grep -q '^ui/messages/'; then
echo "[pre-commit] ui/messages/*.json changed — recompiling paraglide..."
(cd ui && npm run paraglide --silent)
git add -f ui/src/lib/paraglide/messages/
echo "[pre-commit] paraglide output re-staged."
fi

View File

@@ -1,156 +0,0 @@
---
name: ios-ux
description: iOS/SwiftUI UI & UX review and implementation guidelines for LibNovel. Enforces Apple HIG, iOS 17+ APIs, spring animations, haptics, accessibility, performance, and offline handling. Load this skill for any iOS view work.
compatibility: opencode
---
# iOS UI/UX Skill — LibNovel
Load this skill whenever working on SwiftUI views in `ios/`. It defines design standards, review process for screenshots, and implementation rules.
---
## Screenshot Review Process
When the user provides a screenshot of the app:
1. **Analyze first** — identify specific UI/UX issues across these categories:
- Visual hierarchy and spacing
- Typography (size, weight, contrast)
- Color and material usage
- Animation and interactivity gaps
- Accessibility problems
- Deprecated or non-native patterns
2. **Present a numbered list** of suggested improvements with brief rationale for each.
3. **Ask for confirmation** before writing any code: "Should I apply all of these, or only specific ones?"
4. Apply only what the user confirms.
---
## Design System
### Colors & Materials
- **Accent**: `Color.amber` (project-defined). Use for active state, selection indicators, progress fills, and CTAs.
- **Backgrounds**: Prefer `.regularMaterial`, `.ultraThinMaterial`, or `.thinMaterial` over hard-coded `Color.black.opacity(x)` or `Color(.systemBackground)`.
- **Dark overlays** (e.g. full-screen players): Use `KFImage` blurred background + `Color.black.opacity(0.50.6)` overlay. Never use a flat solid black background.
- **Semantic colors**: Use `.primary`, `.secondary`, `.tertiary` foreground styles. Avoid hard-coded `Color.white` except on dark material contexts (full-screen player).
- **No hardcoded color literals** — use `Color+App.swift` extensions or system semantic colors.
### Typography
- Use the SF Pro system font via `.font(.title)`, `.font(.body)`, etc. — never hardcode font names except for intentional stylistic accents (e.g. "Snell Roundhand" for voice watermark).
- Apply `.fontWeight()` and `.fontDesign()` modifiers rather than custom font families.
- Support Dynamic Type — never hardcode a fixed font size as the sole option without a `.minimumScaleFactor` or system font size modifier.
- Hierarchy: title3.bold for primary labels, subheadline for secondary, caption/caption2 for metadata.
### Spacing & Layout
- Minimum touch target: **44×44 pt**. Use `.frame(minWidth: 44, minHeight: 44)` or `.contentShape(Rectangle())` on small icons.
- Prefer 1620 pt horizontal padding on full-width containers; 12 pt for compact inner elements.
- Use `VStack(spacing:)` and `HStack(spacing:)` explicitly — never rely on default spacing for production UI.
- Corner radii: 1214 pt for cards/chips, 10 pt for small badges, 2024 pt for large cover art.
---
## Animation Rules
### Spring Animations (default for all interactive transitions)
- Use `.spring(response:dampingFraction:)` for state-driven layout changes, selection feedback, and appear/disappear transitions.
- Recommended defaults:
- Interactive elements: `response: 0.3, dampingFraction: 0.7`
- Entrance animations: `response: 0.450.5, dampingFraction: 0.7`
- Quick snappy feedback: `response: 0.2, dampingFraction: 0.6`
- Reserve `.easeInOut` only for non-interactive, ambient animations (e.g. opacity pulses, generating overlays).
### SF Symbol Transitions
- Always use `contentTransition(.symbolEffect(.replace.downUp))` when a symbol name changes based on state (play/pause, checkmark/circle, etc.).
- Use `.symbolEffect(.variableColor.cumulative)` for continuous animations (waveform, loading indicators).
- Use `.symbolEffect(.bounce)` for one-shot entrance emphasis (e.g. completion checkmark appearing).
- Use `.symbolEffect(.pulse)` for error/warning states that need attention.
### Repeating Animations
- Use `phaseAnimator` for any looping animation that previously used manual `@State` + `withAnimation` chains.
- Do not use `Timer` publishers for UI animation — prefer `phaseAnimator` or `TimelineView`.
---
## Haptic Feedback
Add `UIImpactFeedbackGenerator` to every user-initiated interactive control:
- `.light` — toggle switches, selection chips, secondary actions, slider drag start.
- `.medium` — primary transport buttons (play/pause, chapter skip), significant confirmations.
- `.heavy` — destructive actions (only if no confirmation dialog).
Pattern:
```swift
Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
// action
} label: { ... }
```
Do **not** add haptics to:
- Programmatic state changes not directly triggered by a tap.
- Buttons inside `List` rows that already use swipe actions.
- Scroll events.
---
## iOS 17+ API Usage
Flag and replace any of the following deprecated patterns:
| Deprecated | Replace with |
|---|---|
| `NavigationView` | `NavigationStack` |
| `@StateObject` / `ObservableObject` (new types only) | `@Observable` macro |
| `DispatchQueue.main.async` | `await MainActor.run` or `@MainActor` |
| Manual `@State` animation chains for repeating loops | `phaseAnimator` |
| `.animation(_:)` without `value:` | `.animation(_:value:)` |
| `AnyView` wrapping for conditional content | `@ViewBuilder` + `Group` |
Do **not** refactor existing `ObservableObject` types to `@Observable` unless explicitly asked — only apply `@Observable` to new types.
---
## Accessibility
Every view must:
- Support VoiceOver: add `.accessibilityLabel()` to icon-only buttons and image views.
- Support Dynamic Type: test that text doesn't truncate at xxxLarge without a layout adjustment.
- Meet contrast ratio: text on tinted backgrounds must be legible — avoid `.opacity(0.25)` or lower for any user-readable text.
- Touch targets ≥ 44pt (see Spacing above).
- Interactive controls must have `.accessibilityAddTraits(.isButton)` if not using `Button`.
- Do not rely solely on color to convey state — pair color with icon or label.
---
## Performance
- **Isolate high-frequency observers**: Any view that observes a `PlaybackProgress` (timer-tick updates) must be a separate sub-view that `@ObservedObject`-observes only the progress object — not the parent view. This prevents the entire parent from re-rendering every 0.5 seconds.
- **Avoid `id()` overuse**: Only use `.id()` to force view recreation when necessary (e.g. background image on track change). Prefer `onChange(of:)` for side effects.
- **Lazy containers**: Use `LazyVStack` / `LazyHStack` inside `ScrollView` for lists of 20+ items. `List` is inherently lazy and does not need this.
- **Image loading**: Always use `KFImage` (Kingfisher) with `.placeholder` for remote images. Never use `AsyncImage` for cover art — it has no disk cache.
- **Avoid `AnyView`**: It breaks structural identity and hurts diffing. Use `@ViewBuilder` or `Group { }` instead.
---
## Offline & Error States
Every view that makes network calls must:
1. Wrap the body in a `VStack` with `OfflineBanner` at the top, gated on `networkMonitor.isConnected`.
2. Suppress network errors silently when offline via `ErrorAlertModifier` — do not show an alert when the device is offline.
3. Gate `.task` / `.onAppear` network calls: `guard networkMonitor.isConnected else { return }`.
4. Show a non-blocking inline empty state (not a full-screen error) for failed loads when online.
---
## Component Checklist (before submitting any view change)
- [ ] All interactive elements ≥ 44pt touch target
- [ ] SF Symbol state changes use `contentTransition(.symbolEffect(...))`
- [ ] State-driven layout transitions use `.spring(response:dampingFraction:)`
- [ ] Tappable controls have haptic feedback
- [ ] No `NavigationView`, no `DispatchQueue.main.async`, no `.animation(_:)` without `value:`
- [ ] High-frequency observers are isolated sub-views
- [ ] Offline state handled with `OfflineBanner` + `NetworkMonitor`
- [ ] VoiceOver labels on icon-only buttons
- [ ] No hardcoded `Color.black` / `Color.white` / `Color(.systemBackground)` where a material applies

View File

@@ -124,6 +124,24 @@ func run() error {
log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — CF AI voices unavailable in backend")
}
// ── Cloudflare Workers AI Image Generation ────────────────────────────────
var imageGenClient cfai.ImageGenClient
if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
imageGenClient = cfai.NewImageGen(cfg.CFAI.AccountID, cfg.CFAI.APIToken)
log.Info("cloudflare AI image generation enabled")
} else {
log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — image generation unavailable")
}
// ── Cloudflare Workers AI Text Generation ─────────────────────────────────
var textGenClient cfai.TextGenClient
if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
textGenClient = cfai.NewTextGen(cfg.CFAI.AccountID, cfg.CFAI.APIToken)
log.Info("cloudflare AI text generation enabled")
} else {
log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — text generation unavailable")
}
// ── Meilisearch (search reads only; indexing is the runner's job) ────────
var searchIndex meili.Client
if cfg.Meilisearch.URL != "" {
@@ -174,6 +192,9 @@ func run() error {
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
ImageGen: imageGenClient,
TextGen: textGenClient,
BookWriter: store,
Log: log,
},
)

View File

@@ -569,6 +569,30 @@ func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 0, map[string]any{"slug": slug, "indexed": count})
}
// handleDedupChapters handles POST /api/admin/dedup-chapters/{slug}.
// Removes duplicate chapters_idx records for a book, keeping the latest record
// per chapter number. Returns the number of duplicate records deleted.
func (s *Server) handleDedupChapters(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
deleted, err := s.deps.BookWriter.DeduplicateChapters(r.Context(), slug)
if err != nil {
s.deps.Log.Error("dedup-chapters failed", "slug", slug, "err", err)
writeJSON(w, http.StatusInternalServerError, map[string]any{
"error": err.Error(),
"deleted": deleted,
})
return
}
s.deps.Log.Info("dedup-chapters complete", "slug", slug, "deleted", deleted)
writeJSON(w, 0, map[string]any{"slug": slug, "deleted": deleted})
}
// ── Audio ──────────────────────────────────────────────────────────────────────
// handleAudioGenerate handles POST /api/audio/{slug}/{n}.

View File

@@ -0,0 +1,728 @@
package backend
// Catalogue enrichment handlers: tagline, genre tagging, content warnings,
// quality scoring, batch cover regeneration, and per-book metadata refresh.
//
// All generation endpoints are admin-only (enforced by the SvelteKit proxy layer).
// All long-running operations support cancellation via r.Context().Done().
// Batch operations use an in-memory cancel registry (cancelJobs map) so the
// frontend can send a cancel request by job ID.
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
)
// ── Cancel registry ────────────────────────────────────────────────────────
// cancelJobsMu guards cancelJobs.
var cancelJobsMu sync.Mutex
// cancelJobs maps a job ID to its CancelFunc. Entries are added when a batch
// job starts and removed when it finishes or is cancelled.
var cancelJobs = map[string]context.CancelFunc{}
func registerCancelJob(id string, cancel context.CancelFunc) {
cancelJobsMu.Lock()
cancelJobs[id] = cancel
cancelJobsMu.Unlock()
}
func deregisterCancelJob(id string) {
cancelJobsMu.Lock()
delete(cancelJobs, id)
cancelJobsMu.Unlock()
}
// ── Tagline ───────────────────────────────────────────────────────────────
// textGenTaglineRequest is the JSON body for POST /api/admin/text-gen/tagline.
type textGenTaglineRequest struct {
Slug string `json:"slug"`
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
}
// textGenTaglineResponse is returned by POST /api/admin/text-gen/tagline.
type textGenTaglineResponse struct {
OldTagline string `json:"old_tagline"`
NewTagline string `json:"new_tagline"`
Model string `json:"model"`
}
// handleAdminTextGenTagline handles POST /api/admin/text-gen/tagline.
// Generates a 1-sentence marketing hook for a book.
func (s *Server) handleAdminTextGenTagline(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
return
}
var req textGenTaglineRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
return
}
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
}
system := `You are a copywriter for a web novel platform. ` +
`Given a book's title, genres, and description, write a single punchy tagline ` +
`(one sentence, under 20 words) that hooks a reader. ` +
`Output ONLY the tagline — no quotes, no labels, no explanation.`
user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s",
meta.Title,
strings.Join(meta.Genres, ", "),
meta.Summary,
)
s.deps.Log.Info("admin: text-gen tagline requested", "slug", req.Slug, "model", model)
result, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: model,
Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}},
MaxTokens: 64,
})
if genErr != nil {
s.deps.Log.Error("admin: text-gen tagline failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
return
}
writeJSON(w, 0, textGenTaglineResponse{
OldTagline: "", // BookMeta has no tagline field yet — always empty
NewTagline: strings.TrimSpace(result),
Model: string(model),
})
}
// ── Genres ────────────────────────────────────────────────────────────────
// textGenGenresRequest is the JSON body for POST /api/admin/text-gen/genres.
type textGenGenresRequest struct {
Slug string `json:"slug"`
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
}
// textGenGenresResponse is returned by POST /api/admin/text-gen/genres.
type textGenGenresResponse struct {
CurrentGenres []string `json:"current_genres"`
ProposedGenres []string `json:"proposed_genres"`
Model string `json:"model"`
}
// handleAdminTextGenGenres handles POST /api/admin/text-gen/genres.
// Suggests a refined genre list based on the book's description.
func (s *Server) handleAdminTextGenGenres(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
return
}
var req textGenGenresRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
return
}
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
}
system := `You are a genre classification expert for a web novel platform. ` +
`Given a book's title and description, return a JSON array of 26 genre tags. ` +
`Use only well-known web novel genres such as: ` +
`Action, Adventure, Comedy, Drama, Fantasy, Historical, Horror, Isekai, Josei, ` +
`Martial Arts, Mature, Mecha, Mystery, Psychological, Romance, School Life, ` +
`Sci-fi, Seinen, Shoujo, Shounen, Slice of Life, Supernatural, System, Tragedy, Wuxia, Xianxia. ` +
`Output ONLY a raw JSON array of strings — no prose, no markdown, no explanation. ` +
`Example: ["Fantasy","Adventure","Action"]`
user := fmt.Sprintf("Title: %s\nCurrent genres: %s\n\nDescription:\n%s",
meta.Title,
strings.Join(meta.Genres, ", "),
meta.Summary,
)
s.deps.Log.Info("admin: text-gen genres requested", "slug", req.Slug, "model", model)
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: model,
Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}},
MaxTokens: 128,
})
if genErr != nil {
s.deps.Log.Error("admin: text-gen genres failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
return
}
proposed := parseStringArrayJSON(raw)
writeJSON(w, 0, textGenGenresResponse{
CurrentGenres: meta.Genres,
ProposedGenres: proposed,
Model: string(model),
})
}
// handleAdminTextGenApplyGenres handles POST /api/admin/text-gen/genres/apply.
// Persists the confirmed genre list to PocketBase.
func (s *Server) handleAdminTextGenApplyGenres(w http.ResponseWriter, r *http.Request) {
if s.deps.BookWriter == nil {
jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
return
}
var req struct {
Slug string `json:"slug"`
Genres []string `json:"genres"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
return
}
meta.Genres = req.Genres
if err := s.deps.BookWriter.WriteMetadata(r.Context(), meta); err != nil {
s.deps.Log.Error("admin: apply genres failed", "slug", req.Slug, "err", err)
jsonError(w, http.StatusInternalServerError, "write metadata: "+err.Error())
return
}
s.deps.Log.Info("admin: genres applied", "slug", req.Slug, "genres", req.Genres)
writeJSON(w, 0, map[string]any{"updated": true})
}
// ── Content warnings ──────────────────────────────────────────────────────
// textGenContentWarningsRequest is the JSON body for POST /api/admin/text-gen/content-warnings.
type textGenContentWarningsRequest struct {
Slug string `json:"slug"`
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
}
// textGenContentWarningsResponse is returned by POST /api/admin/text-gen/content-warnings.
type textGenContentWarningsResponse struct {
Warnings []string `json:"warnings"`
Model string `json:"model"`
}
// handleAdminTextGenContentWarnings handles POST /api/admin/text-gen/content-warnings.
// Detects mature or sensitive themes in a book's description.
func (s *Server) handleAdminTextGenContentWarnings(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
return
}
var req textGenContentWarningsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
return
}
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
}
system := `You are a content moderation assistant for a web novel platform. ` +
`Given a book's title, genres, and description, detect any content warnings that should be shown to readers. ` +
`Choose only relevant warnings from: Violence, Strong Language, Sexual Content, Mature Themes, ` +
`Dark Themes, Gore, Torture, Abuse, Drug Use, Suicide/Self-Harm. ` +
`If the book is clean, return an empty array. ` +
`Output ONLY a raw JSON array of strings — no prose, no markdown. ` +
`Example: ["Violence","Dark Themes"]`
user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s",
meta.Title,
strings.Join(meta.Genres, ", "),
meta.Summary,
)
s.deps.Log.Info("admin: text-gen content-warnings requested", "slug", req.Slug, "model", model)
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: model,
Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}},
MaxTokens: 128,
})
if genErr != nil {
s.deps.Log.Error("admin: text-gen content-warnings failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
return
}
warnings := parseStringArrayJSON(raw)
writeJSON(w, 0, textGenContentWarningsResponse{
Warnings: warnings,
Model: string(model),
})
}
// ── Quality score ─────────────────────────────────────────────────────────
// textGenQualityScoreRequest is the JSON body for POST /api/admin/text-gen/quality-score.
type textGenQualityScoreRequest struct {
Slug string `json:"slug"`
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
}
// textGenQualityScoreResponse is returned by POST /api/admin/text-gen/quality-score.
type textGenQualityScoreResponse struct {
Score int `json:"score"` // 15
Feedback string `json:"feedback"` // brief reasoning
Model string `json:"model"`
}
// handleAdminTextGenQualityScore handles POST /api/admin/text-gen/quality-score.
// Rates the book description quality on a 15 scale with brief feedback.
func (s *Server) handleAdminTextGenQualityScore(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured")
return
}
var req textGenQualityScoreRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
return
}
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
}
system := `You are a book description quality reviewer for a web novel platform. ` +
`Rate the provided description on a scale of 15 where: ` +
`1=poor (vague/too short), 2=below average, 3=average, 4=good, 5=excellent (engaging/detailed). ` +
`Respond with ONLY a JSON object: {"score": <1-5>, "feedback": "<one sentence explanation>"}. ` +
`No markdown, no extra text.`
user := fmt.Sprintf("Title: %s\nGenres: %s\n\nDescription:\n%s",
meta.Title,
strings.Join(meta.Genres, ", "),
meta.Summary,
)
s.deps.Log.Info("admin: text-gen quality-score requested", "slug", req.Slug, "model", model)
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: model,
Messages: []cfai.TextMessage{{Role: "system", Content: system}, {Role: "user", Content: user}},
MaxTokens: 128,
})
if genErr != nil {
s.deps.Log.Error("admin: text-gen quality-score failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
return
}
var parsed struct {
Score int `json:"score"`
Feedback string `json:"feedback"`
}
// Strip markdown fences if any.
clean := extractJSONObject(raw)
if err := json.Unmarshal([]byte(clean), &parsed); err != nil {
// Fallback: try to extract a digit.
parsed.Score = 0
for _, ch := range raw {
if ch >= '1' && ch <= '5' {
parsed.Score = int(ch - '0')
break
}
}
parsed.Feedback = strings.TrimSpace(raw)
}
writeJSON(w, 0, textGenQualityScoreResponse{
Score: parsed.Score,
Feedback: parsed.Feedback,
Model: string(model),
})
}
// ── Batch cover regeneration ──────────────────────────────────────────────
// batchCoverEvent is one SSE event emitted during batch cover regeneration.
type batchCoverEvent struct {
// JobID is the opaque identifier clients use to cancel this job.
JobID string `json:"job_id,omitempty"`
Done int `json:"done"`
Total int `json:"total"`
Slug string `json:"slug,omitempty"`
Error string `json:"error,omitempty"`
Skipped bool `json:"skipped,omitempty"`
Finish bool `json:"finish,omitempty"`
}
// handleAdminBatchCovers handles POST /api/admin/catalogue/batch-covers.
//
// Streams SSE events as it generates covers for every book that has no cover
// stored in MinIO. Each event carries progress info. The final event has Finish=true.
//
// The job can be cancelled by calling POST /api/admin/catalogue/batch-covers/cancel
// with body {"job_id":"..."}.
func (s *Server) handleAdminBatchCovers(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil || s.deps.ImageGen == nil {
jsonError(w, http.StatusServiceUnavailable, "image/text generation not configured")
return
}
if s.deps.CoverStore == nil {
jsonError(w, http.StatusServiceUnavailable, "cover store not configured")
return
}
var reqBody struct {
Model string `json:"model"`
NumSteps int `json:"num_steps"`
Width int `json:"width"`
Height int `json:"height"`
}
// Body is optional — defaults used if absent.
json.NewDecoder(r.Body).Decode(&reqBody) //nolint:errcheck
books, err := s.deps.BookReader.ListBooks(r.Context())
if err != nil {
jsonError(w, http.StatusInternalServerError, "list books: "+err.Error())
return
}
// Generate a unique job ID.
jobID := randomHex(8)
ctx, cancel := context.WithCancel(r.Context())
registerCancelJob(jobID, cancel)
defer deregisterCancelJob(jobID)
defer cancel()
// SSE headers.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
flusher, canFlush := w.(http.Flusher)
sseWrite := func(evt batchCoverEvent) {
b, _ := json.Marshal(evt)
fmt.Fprintf(w, "data: %s\n\n", b)
if canFlush {
flusher.Flush()
}
}
total := len(books)
done := 0
// Send initial event with jobID so frontend can store it for cancellation.
sseWrite(batchCoverEvent{JobID: jobID, Done: 0, Total: total})
for _, book := range books {
if ctx.Err() != nil {
break
}
// Check if cover already exists.
hasCover := s.deps.CoverStore.CoverExists(ctx, book.Slug)
if hasCover {
done++
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Skipped: true})
continue
}
// Build a prompt from the book metadata.
prompt := buildCoverPrompt(book)
// Generate the image via CF AI.
imgBytes, genErr := s.deps.ImageGen.GenerateImage(ctx, cfai.ImageRequest{
Prompt: prompt,
NumSteps: reqBody.NumSteps,
Width: reqBody.Width,
Height: reqBody.Height,
})
if genErr != nil {
done++
s.deps.Log.Error("batch-covers: image gen failed", "slug", book.Slug, "err", genErr)
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Error: genErr.Error()})
continue
}
// Save to CoverStore.
if saveErr := s.deps.CoverStore.PutCover(ctx, book.Slug, imgBytes, "image/png"); saveErr != nil {
done++
s.deps.Log.Error("batch-covers: save failed", "slug", book.Slug, "err", saveErr)
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug, Error: saveErr.Error()})
continue
}
done++
s.deps.Log.Info("batch-covers: cover generated", "slug", book.Slug)
sseWrite(batchCoverEvent{Done: done, Total: total, Slug: book.Slug})
}
sseWrite(batchCoverEvent{Done: done, Total: total, Finish: true})
}
// handleAdminBatchCoversCancel handles POST /api/admin/catalogue/batch-covers/cancel.
// Cancels an in-progress batch cover job by its job ID.
func (s *Server) handleAdminBatchCoversCancel(w http.ResponseWriter, r *http.Request) {
var req struct {
JobID string `json:"job_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.JobID == "" {
jsonError(w, http.StatusBadRequest, "job_id is required")
return
}
cancelJobsMu.Lock()
cancel, ok := cancelJobs[req.JobID]
cancelJobsMu.Unlock()
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("job %q not found", req.JobID))
return
}
cancel()
s.deps.Log.Info("batch-covers: job cancelled", "job_id", req.JobID)
writeJSON(w, 0, map[string]any{"cancelled": true})
}
// ── Refresh metadata (per-book) ────────────────────────────────────────────
// refreshMetadataEvent is one SSE event during per-book metadata refresh.
type refreshMetadataEvent struct {
Step string `json:"step"` // "description" | "tagline" | "cover"
Done bool `json:"done"`
Error string `json:"error,omitempty"`
}
// handleAdminRefreshMetadata handles POST /api/admin/catalogue/refresh-metadata/{slug}.
//
// Runs description → tagline → cover generation in sequence for a single book
// and streams SSE progress. Interruptable via client disconnect (r.Context()).
func (s *Server) handleAdminRefreshMetadata(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", slug))
return
}
// SSE headers.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no")
flusher, canFlush := w.(http.Flusher)
sseWrite := func(evt refreshMetadataEvent) {
b, _ := json.Marshal(evt)
fmt.Fprintf(w, "data: %s\n\n", b)
if canFlush {
flusher.Flush()
}
}
ctx := r.Context()
// Step 1 — description.
if s.deps.TextGen != nil {
if ctx.Err() == nil {
newDesc, genErr := s.deps.TextGen.Generate(ctx, cfai.TextRequest{
Model: cfai.DefaultTextModel,
Messages: []cfai.TextMessage{
{Role: "system", Content: `You are a book description writer for a web novel platform. Write an improved description. Respond with ONLY the new description text — no title, no labels, no markdown.`},
{Role: "user", Content: fmt.Sprintf("Title: %s\nGenres: %s\n\nCurrent description:\n%s\n\nInstructions: Write a compelling 24 sentence description. Keep it spoiler-free and engaging.", meta.Title, strings.Join(meta.Genres, ", "), meta.Summary)},
},
MaxTokens: 512,
})
if genErr == nil && strings.TrimSpace(newDesc) != "" && s.deps.BookWriter != nil {
meta.Summary = strings.TrimSpace(newDesc)
if writeErr := s.deps.BookWriter.WriteMetadata(ctx, meta); writeErr != nil {
sseWrite(refreshMetadataEvent{Step: "description", Error: writeErr.Error()})
} else {
sseWrite(refreshMetadataEvent{Step: "description"})
}
} else if genErr != nil {
sseWrite(refreshMetadataEvent{Step: "description", Error: genErr.Error()})
}
}
}
// Step 2 — cover.
if s.deps.ImageGen != nil && s.deps.CoverStore != nil {
if ctx.Err() == nil {
prompt := buildCoverPrompt(meta)
imgBytes, genErr := s.deps.ImageGen.GenerateImage(ctx, cfai.ImageRequest{Prompt: prompt})
if genErr == nil {
if saveErr := s.deps.CoverStore.PutCover(ctx, slug, imgBytes, "image/png"); saveErr != nil {
sseWrite(refreshMetadataEvent{Step: "cover", Error: saveErr.Error()})
} else {
sseWrite(refreshMetadataEvent{Step: "cover"})
}
} else {
sseWrite(refreshMetadataEvent{Step: "cover", Error: genErr.Error()})
}
}
}
sseWrite(refreshMetadataEvent{Step: "done", Done: true})
}
// ── Helpers ───────────────────────────────────────────────────────────────
// parseStringArrayJSON extracts a JSON string array from model output,
// tolerating markdown fences and surrounding prose.
func parseStringArrayJSON(raw string) []string {
s := raw
if idx := strings.Index(s, "```json"); idx >= 0 {
s = s[idx+7:]
} else if idx := strings.Index(s, "```"); idx >= 0 {
s = s[idx+3:]
}
if idx := strings.LastIndex(s, "```"); idx >= 0 {
s = s[:idx]
}
start := strings.Index(s, "[")
end := strings.LastIndex(s, "]")
if start < 0 || end <= start {
return nil
}
s = s[start : end+1]
var out []string
json.Unmarshal([]byte(s), &out) //nolint:errcheck
return out
}
// extractJSONObject finds the first {...} object in a string.
func extractJSONObject(raw string) string {
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end <= start {
return raw
}
return raw[start : end+1]
}
// buildCoverPrompt constructs a prompt string for cover generation from a book.
func buildCoverPrompt(meta domain.BookMeta) string {
parts := []string{"book cover art"}
if meta.Title != "" {
parts = append(parts, "titled \""+meta.Title+"\"")
}
if len(meta.Genres) > 0 {
parts = append(parts, strings.Join(meta.Genres, ", ")+" genre")
}
if meta.Summary != "" {
summary := meta.Summary
if len(summary) > 200 {
summary = summary[:200]
}
parts = append(parts, summary)
}
return strings.Join(parts, ", ")
}
// randomHex returns a random hex string of n bytes.
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}

View File

@@ -0,0 +1,290 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/libnovel/backend/internal/cfai"
)
// handleAdminImageGenModels handles GET /api/admin/image-gen/models.
// Returns the list of supported Cloudflare AI image generation models.
func (s *Server) handleAdminImageGenModels(w http.ResponseWriter, r *http.Request) {
if s.deps.ImageGen == nil {
jsonError(w, http.StatusServiceUnavailable, "image generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
return
}
models := s.deps.ImageGen.Models()
writeJSON(w, 0, map[string]any{"models": models})
}
// imageGenRequest is the JSON body for POST /api/admin/image-gen.
type imageGenRequest struct {
// Prompt is the text description of the desired image.
Prompt string `json:"prompt"`
// Model is the CF Workers AI model ID (e.g. "@cf/black-forest-labs/flux-2-dev").
// Defaults to the recommended model for the given type.
Model string `json:"model"`
// Type is either "cover" or "chapter".
Type string `json:"type"`
// Slug is the book slug. Required for cover; required for chapter.
Slug string `json:"slug"`
// Chapter number (1-based). Required when type == "chapter".
Chapter int `json:"chapter"`
// ReferenceImageB64 is an optional base64-encoded PNG/JPEG reference image.
// When present the img2img path is used.
ReferenceImageB64 string `json:"reference_image_b64"`
// NumSteps overrides inference steps (default 20).
NumSteps int `json:"num_steps"`
// Width / Height override output dimensions (0 = model default).
Width int `json:"width"`
Height int `json:"height"`
// Guidance overrides prompt guidance scale (0 = model default).
Guidance float64 `json:"guidance"`
// Strength for img2img: 0.01.0, default 0.75.
Strength float64 `json:"strength"`
// SaveToCover when true stores the result as the book cover in MinIO
// (overwriting any existing cover) and sets the book's cover URL.
// Only valid when type == "cover".
SaveToCover bool `json:"save_to_cover"`
}
// imageGenResponse is the JSON body returned by POST /api/admin/image-gen.
type imageGenResponse struct {
// ImageB64 is the generated image as a base64-encoded PNG string.
ImageB64 string `json:"image_b64"`
// ContentType is "image/png" or "image/jpeg".
ContentType string `json:"content_type"`
// Saved indicates whether the image was persisted to MinIO.
Saved bool `json:"saved"`
// CoverURL is the URL the cover is now served from (only set when Saved==true).
CoverURL string `json:"cover_url,omitempty"`
// Model is the model that was used.
Model string `json:"model"`
// Bytes is the raw image size in bytes.
Bytes int `json:"bytes"`
}
// handleAdminImageGen handles POST /api/admin/image-gen.
//
// Generates an image using Cloudflare Workers AI and optionally stores it.
// Multipart/form-data is also accepted so the reference image can be uploaded
// directly; otherwise the reference is expected as base64 JSON.
func (s *Server) handleAdminImageGen(w http.ResponseWriter, r *http.Request) {
if s.deps.ImageGen == nil {
jsonError(w, http.StatusServiceUnavailable, "image generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
return
}
var req imageGenRequest
var refImageData []byte
ct := r.Header.Get("Content-Type")
if strings.HasPrefix(ct, "multipart/form-data") {
// Multipart: parse JSON fields from a "json" part + optional "reference" file part.
if err := r.ParseMultipartForm(32 << 20); err != nil {
jsonError(w, http.StatusBadRequest, "parse multipart: "+err.Error())
return
}
if jsonPart := r.FormValue("json"); jsonPart != "" {
if err := json.Unmarshal([]byte(jsonPart), &req); err != nil {
jsonError(w, http.StatusBadRequest, "parse json field: "+err.Error())
return
}
}
if f, _, err := r.FormFile("reference"); err == nil {
defer f.Close()
refImageData, _ = io.ReadAll(f)
}
} else {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if req.ReferenceImageB64 != "" {
var decErr error
refImageData, decErr = base64.StdEncoding.DecodeString(req.ReferenceImageB64)
if decErr != nil {
// Try std without padding
refImageData, decErr = base64.RawStdEncoding.DecodeString(req.ReferenceImageB64)
if decErr != nil {
jsonError(w, http.StatusBadRequest, "decode reference_image_b64: "+decErr.Error())
return
}
}
}
}
if strings.TrimSpace(req.Prompt) == "" {
jsonError(w, http.StatusBadRequest, "prompt is required")
return
}
if req.Type != "cover" && req.Type != "chapter" {
jsonError(w, http.StatusBadRequest, `type must be "cover" or "chapter"`)
return
}
if req.Slug == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if req.Type == "chapter" && req.Chapter <= 0 {
jsonError(w, http.StatusBadRequest, "chapter must be > 0 when type is chapter")
return
}
// Resolve model
model := cfai.ImageModel(req.Model)
if model == "" {
if req.Type == "cover" {
model = cfai.DefaultImageModel
} else {
model = cfai.ImageModelFlux2Klein4B
}
}
imgReq := cfai.ImageRequest{
Prompt: req.Prompt,
Model: model,
NumSteps: req.NumSteps,
Width: req.Width,
Height: req.Height,
Guidance: req.Guidance,
Strength: req.Strength,
}
s.deps.Log.Info("admin: image gen requested",
"type", req.Type, "slug", req.Slug, "chapter", req.Chapter,
"model", model, "has_reference", len(refImageData) > 0)
var imgData []byte
var genErr error
if len(refImageData) > 0 {
imgData, genErr = s.deps.ImageGen.GenerateImageFromReference(r.Context(), imgReq, refImageData)
} else {
imgData, genErr = s.deps.ImageGen.GenerateImage(r.Context(), imgReq)
}
if genErr != nil {
s.deps.Log.Error("admin: image gen failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "image generation failed: "+genErr.Error())
return
}
contentType := sniffImageContentType(imgData)
// ── Optional persistence ──────────────────────────────────────────────────
var saved bool
var coverURL string
if req.SaveToCover && req.Type == "cover" && s.deps.CoverStore != nil {
if err := s.deps.CoverStore.PutCover(r.Context(), req.Slug, imgData, contentType); err != nil {
s.deps.Log.Error("admin: save generated cover failed", "slug", req.Slug, "err", err)
// Non-fatal: still return the image
} else {
saved = true
coverURL = fmt.Sprintf("/api/cover/novelfire.net/%s", req.Slug)
s.deps.Log.Info("admin: generated cover saved", "slug", req.Slug, "bytes", len(imgData))
}
}
// Encode result as base64
b64 := base64.StdEncoding.EncodeToString(imgData)
writeJSON(w, 0, imageGenResponse{
ImageB64: b64,
ContentType: contentType,
Saved: saved,
CoverURL: coverURL,
Model: string(model),
Bytes: len(imgData),
})
}
// saveCoverRequest is the JSON body for POST /api/admin/image-gen/save-cover.
type saveCoverRequest struct {
// Slug is the book slug whose cover should be overwritten.
Slug string `json:"slug"`
// ImageB64 is the base64-encoded image bytes (PNG or JPEG).
ImageB64 string `json:"image_b64"`
}
// handleAdminImageGenSaveCover handles POST /api/admin/image-gen/save-cover.
//
// Accepts a pre-generated image as base64 and stores it as the book cover in
// MinIO, replacing the existing one. Does not call Cloudflare AI at all.
func (s *Server) handleAdminImageGenSaveCover(w http.ResponseWriter, r *http.Request) {
if s.deps.CoverStore == nil {
jsonError(w, http.StatusServiceUnavailable, "cover store not configured")
return
}
var req saveCoverRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if req.Slug == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if req.ImageB64 == "" {
jsonError(w, http.StatusBadRequest, "image_b64 is required")
return
}
imgData, err := base64.StdEncoding.DecodeString(req.ImageB64)
if err != nil {
imgData, err = base64.RawStdEncoding.DecodeString(req.ImageB64)
if err != nil {
jsonError(w, http.StatusBadRequest, "decode image_b64: "+err.Error())
return
}
}
contentType := sniffImageContentType(imgData)
if err := s.deps.CoverStore.PutCover(r.Context(), req.Slug, imgData, contentType); err != nil {
s.deps.Log.Error("admin: save-cover failed", "slug", req.Slug, "err", err)
jsonError(w, http.StatusInternalServerError, "save cover: "+err.Error())
return
}
s.deps.Log.Info("admin: cover saved via image-gen", "slug", req.Slug, "bytes", len(imgData))
writeJSON(w, 0, map[string]any{
"saved": true,
"cover_url": fmt.Sprintf("/api/cover/novelfire.net/%s", req.Slug),
"bytes": len(imgData),
})
}
// sniffImageContentType returns the MIME type of the image bytes.
func sniffImageContentType(data []byte) string {
if len(data) >= 4 {
// PNG: 0x89 P N G
if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4e && data[3] == 0x47 {
return "image/png"
}
// JPEG: FF D8 FF
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
return "image/jpeg"
}
// WebP: RIFF....WEBP
if len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' {
return "image/webp"
}
}
return "image/png"
}

View File

@@ -0,0 +1,486 @@
package backend
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
)
// chapterNamesBatchSize is the number of chapters sent per LLM request.
// Keeps output well within the 4096-token response limit (~30 tokens/title).
const chapterNamesBatchSize = 100
// handleAdminTextGenModels handles GET /api/admin/text-gen/models.
// Returns the list of supported Cloudflare AI text generation models.
func (s *Server) handleAdminTextGenModels(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
return
}
models := s.deps.TextGen.Models()
writeJSON(w, 0, map[string]any{"models": models})
}
// ── Chapter names ─────────────────────────────────────────────────────────────
// textGenChapterNamesRequest is the JSON body for POST /api/admin/text-gen/chapter-names.
type textGenChapterNamesRequest struct {
// Slug is the book slug whose chapters to process.
Slug string `json:"slug"`
// Pattern is a free-text description of the desired naming convention,
// e.g. "Chapter {n}: {brief scene description}".
Pattern string `json:"pattern"`
// Model is the CF Workers AI model ID. Defaults to the recommended model when empty.
Model string `json:"model"`
// MaxTokens limits response length (0 = model default).
MaxTokens int `json:"max_tokens"`
}
// proposedChapterTitle is a single chapter with its AI-proposed title.
type proposedChapterTitle struct {
Number int `json:"number"`
// OldTitle is the current title stored in the database.
OldTitle string `json:"old_title"`
// NewTitle is the AI-proposed replacement.
NewTitle string `json:"new_title"`
}
// chapterNamesBatchEvent is one SSE event emitted per processed batch.
type chapterNamesBatchEvent struct {
// Batch is the 1-based batch index.
Batch int `json:"batch"`
// TotalBatches is the total number of batches.
TotalBatches int `json:"total_batches"`
// ChaptersDone is the cumulative count of chapters processed so far.
ChaptersDone int `json:"chapters_done"`
// TotalChapters is the total chapter count for this book.
TotalChapters int `json:"total_chapters"`
// Model is the CF AI model used.
Model string `json:"model"`
// Chapters contains the proposed titles for this batch.
Chapters []proposedChapterTitle `json:"chapters"`
// Error is non-empty if this batch failed.
Error string `json:"error,omitempty"`
// Done is true on the final sentinel event (no Chapters).
Done bool `json:"done,omitempty"`
}
// handleAdminTextGenChapterNames handles POST /api/admin/text-gen/chapter-names.
//
// Splits all chapters into batches of chapterNamesBatchSize, sends each batch
// to the LLM sequentially, and streams results back as Server-Sent Events so
// the frontend can show live progress. Each SSE data line is a JSON-encoded
// chapterNamesBatchEvent. The final event has Done=true.
//
// Does NOT persist anything — the frontend shows a diff and the user must
// confirm via POST /api/admin/text-gen/chapter-names/apply.
func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
return
}
var req textGenChapterNamesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if strings.TrimSpace(req.Pattern) == "" {
jsonError(w, http.StatusBadRequest, "pattern is required")
return
}
// Load existing chapter list.
chapters, err := s.deps.BookReader.ListChapters(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "list chapters: "+err.Error())
return
}
if len(chapters) == 0 {
jsonError(w, http.StatusNotFound, fmt.Sprintf("no chapters found for slug %q", req.Slug))
return
}
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
}
// 4096 tokens comfortably fits 100 chapter titles (~30 tokens each).
maxTokens := req.MaxTokens
if maxTokens <= 0 {
maxTokens = 4096
}
// Index existing titles for old/new diff.
existing := make(map[int]string, len(chapters))
for _, ch := range chapters {
existing[ch.Number] = ch.Title
}
// Partition chapters into batches.
batches := chunkChapters(chapters, chapterNamesBatchSize)
totalBatches := len(batches)
s.deps.Log.Info("admin: text-gen chapter-names requested",
"slug", req.Slug, "chapters", len(chapters),
"batches", totalBatches, "model", model, "max_tokens", maxTokens)
systemPrompt := `You are a chapter title editor for a web novel platform. ` +
`The user provides a list of chapter numbers with their current titles, ` +
`and a naming pattern template. ` +
`Your job: produce one new title for every chapter, following the pattern exactly. ` +
`Pattern placeholders: {n} = the chapter number (integer), {scene} = a very short (25 word) scene hint derived from the existing title. ` +
`RULES: ` +
`1. Do NOT include the chapter number inside the title text — the {n} placeholder is already in the pattern. ` +
`2. Do NOT include any prefix like "Chapter X -" or "Chapter X:" inside the title field itself. ` +
`3. The "title" field in your JSON must be the fully-rendered string (e.g. if pattern is "Chapter {n}: {scene}", output "Chapter 3: The Bet"). ` +
`4. Respond ONLY with a raw JSON array — no prose, no markdown fences, no explanation. ` +
`5. Each element: {"number": <int>, "title": <string>}. ` +
`6. Output every chapter in the input list, in order. Do not skip any.`
// Switch to SSE before writing anything.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
flusher, canFlush := w.(http.Flusher)
sseWrite := func(evt chapterNamesBatchEvent) {
b, _ := json.Marshal(evt)
fmt.Fprintf(w, "data: %s\n\n", b)
if canFlush {
flusher.Flush()
}
}
chaptersDone := 0
for i, batch := range batches {
if r.Context().Err() != nil {
return // client disconnected
}
var chapterListSB strings.Builder
for _, ch := range batch {
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
}
userPrompt := fmt.Sprintf("Naming pattern: %s\n\nChapters:\n%s", req.Pattern, chapterListSB.String())
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: model,
Messages: []cfai.TextMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
MaxTokens: maxTokens,
})
if genErr != nil {
s.deps.Log.Error("admin: text-gen chapter-names batch failed",
"batch", i+1, "err", genErr)
sseWrite(chapterNamesBatchEvent{
Batch: i + 1,
TotalBatches: totalBatches,
ChaptersDone: chaptersDone,
TotalChapters: len(chapters),
Model: string(model),
Error: genErr.Error(),
})
continue
}
proposed := parseChapterTitlesJSON(raw)
result := make([]proposedChapterTitle, 0, len(proposed))
for _, p := range proposed {
result = append(result, proposedChapterTitle{
Number: p.Number,
OldTitle: existing[p.Number],
NewTitle: p.Title,
})
}
chaptersDone += len(batch)
sseWrite(chapterNamesBatchEvent{
Batch: i + 1,
TotalBatches: totalBatches,
ChaptersDone: chaptersDone,
TotalChapters: len(chapters),
Model: string(model),
Chapters: result,
})
}
// Final sentinel event.
sseWrite(chapterNamesBatchEvent{Done: true, TotalChapters: len(chapters), Model: string(model)})
}
// chunkChapters splits a chapter slice into batches of at most size n.
func chunkChapters(chapters []domain.ChapterInfo, n int) [][]domain.ChapterInfo {
var batches [][]domain.ChapterInfo
for len(chapters) > 0 {
end := n
if end > len(chapters) {
end = len(chapters)
}
batches = append(batches, chapters[:end])
chapters = chapters[end:]
}
return batches
}
// parseChapterTitlesJSON extracts the JSON array from a model response.
// It tolerates markdown fences and surrounding prose.
type rawChapterTitle struct {
Number int `json:"number"`
Title string `json:"title"`
}
func parseChapterTitlesJSON(raw string) []rawChapterTitle {
// Strip markdown fences if present.
s := raw
if idx := strings.Index(s, "```json"); idx >= 0 {
s = s[idx+7:]
} else if idx := strings.Index(s, "```"); idx >= 0 {
s = s[idx+3:]
}
if idx := strings.LastIndex(s, "```"); idx >= 0 {
s = s[:idx]
}
// Find the JSON array boundaries.
start := strings.Index(s, "[")
end := strings.LastIndex(s, "]")
if start < 0 || end <= start {
return nil
}
s = s[start : end+1]
var out []rawChapterTitle
json.Unmarshal([]byte(s), &out) //nolint:errcheck
return out
}
// ── Apply chapter names ───────────────────────────────────────────────────────
// applyChapterNamesRequest is the JSON body for POST /api/admin/text-gen/chapter-names/apply.
type applyChapterNamesRequest struct {
// Slug is the book slug to update.
Slug string `json:"slug"`
// Chapters is the list of chapters to save (number + new_title pairs).
// The UI may modify individual titles before confirming.
Chapters []applyChapterEntry `json:"chapters"`
}
type applyChapterEntry struct {
Number int `json:"number"`
Title string `json:"title"`
}
// handleAdminTextGenApplyChapterNames handles POST /api/admin/text-gen/chapter-names/apply.
//
// Persists the confirmed chapter titles to PocketBase chapters_idx.
func (s *Server) handleAdminTextGenApplyChapterNames(w http.ResponseWriter, r *http.Request) {
if s.deps.BookWriter == nil {
jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
return
}
var req applyChapterNamesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if len(req.Chapters) == 0 {
jsonError(w, http.StatusBadRequest, "chapters is required")
return
}
refs := make([]domain.ChapterRef, 0, len(req.Chapters))
for _, ch := range req.Chapters {
if ch.Number <= 0 {
continue
}
refs = append(refs, domain.ChapterRef{
Number: ch.Number,
Title: strings.TrimSpace(ch.Title),
})
}
if err := s.deps.BookWriter.WriteChapterRefs(r.Context(), req.Slug, refs); err != nil {
s.deps.Log.Error("admin: apply chapter names failed", "slug", req.Slug, "err", err)
jsonError(w, http.StatusInternalServerError, "write chapter refs: "+err.Error())
return
}
s.deps.Log.Info("admin: chapter names applied", "slug", req.Slug, "count", len(refs))
writeJSON(w, 0, map[string]any{"updated": len(refs)})
}
// ── Book description ──────────────────────────────────────────────────────────
// textGenDescriptionRequest is the JSON body for POST /api/admin/text-gen/description.
type textGenDescriptionRequest struct {
// Slug is the book slug whose description to regenerate.
Slug string `json:"slug"`
// Instructions is an optional free-text hint for the AI,
// e.g. "Write a 3-sentence blurb, avoid spoilers, dramatic tone."
Instructions string `json:"instructions"`
// Model is the CF Workers AI model ID. Defaults to recommended when empty.
Model string `json:"model"`
// MaxTokens limits response length (0 = model default).
MaxTokens int `json:"max_tokens"`
}
// textGenDescriptionResponse is the JSON body returned by POST /api/admin/text-gen/description.
type textGenDescriptionResponse struct {
// OldDescription is the current summary stored in the database.
OldDescription string `json:"old_description"`
// NewDescription is the AI-proposed replacement.
NewDescription string `json:"new_description"`
// Model is the model that was used.
Model string `json:"model"`
}
// handleAdminTextGenDescription handles POST /api/admin/text-gen/description.
//
// Reads the current book metadata, sends it to the LLM, and returns a proposed
// new description. Does NOT persist anything — the user must confirm via
// POST /api/admin/text-gen/description/apply.
func (s *Server) handleAdminTextGenDescription(w http.ResponseWriter, r *http.Request) {
if s.deps.TextGen == nil {
jsonError(w, http.StatusServiceUnavailable, "text generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)")
return
}
var req textGenDescriptionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
// Load current book metadata.
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
return
}
systemPrompt := `You are a book description writer for a web novel platform. ` +
`Given a book's title, author, genres, and current description, write an improved ` +
`description that accurately captures the story. ` +
`Respond with ONLY the new description text — no title, no labels, no markdown, no quotes.`
instructions := strings.TrimSpace(req.Instructions)
if instructions == "" {
instructions = "Write a compelling 24 sentence description. Keep it spoiler-free and engaging."
}
userPrompt := fmt.Sprintf(
"Title: %s\nAuthor: %s\nGenres: %s\nStatus: %s\n\nCurrent description:\n%s\n\nInstructions: %s",
meta.Title,
meta.Author,
strings.Join(meta.Genres, ", "),
meta.Status,
meta.Summary,
instructions,
)
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
}
s.deps.Log.Info("admin: text-gen description requested",
"slug", req.Slug, "model", model)
newDesc, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: model,
Messages: []cfai.TextMessage{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
MaxTokens: req.MaxTokens,
})
if genErr != nil {
s.deps.Log.Error("admin: text-gen description failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
return
}
writeJSON(w, 0, textGenDescriptionResponse{
OldDescription: meta.Summary,
NewDescription: strings.TrimSpace(newDesc),
Model: string(model),
})
}
// ── Apply description ─────────────────────────────────────────────────────────
// applyDescriptionRequest is the JSON body for POST /api/admin/text-gen/description/apply.
type applyDescriptionRequest struct {
// Slug is the book slug to update.
Slug string `json:"slug"`
// Description is the new summary text to persist.
Description string `json:"description"`
}
// handleAdminTextGenApplyDescription handles POST /api/admin/text-gen/description/apply.
//
// Updates only the summary field in PocketBase, leaving all other book metadata
// unchanged.
func (s *Server) handleAdminTextGenApplyDescription(w http.ResponseWriter, r *http.Request) {
if s.deps.BookWriter == nil {
jsonError(w, http.StatusServiceUnavailable, "book writer not configured")
return
}
var req applyDescriptionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
return
}
if strings.TrimSpace(req.Slug) == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if strings.TrimSpace(req.Description) == "" {
jsonError(w, http.StatusBadRequest, "description is required")
return
}
// Read existing metadata so we can write it back with only summary changed.
meta, ok, err := s.deps.BookReader.ReadMetadata(r.Context(), req.Slug)
if err != nil {
jsonError(w, http.StatusInternalServerError, "read metadata: "+err.Error())
return
}
if !ok {
jsonError(w, http.StatusNotFound, fmt.Sprintf("book %q not found", req.Slug))
return
}
meta.Summary = strings.TrimSpace(req.Description)
if err := s.deps.BookWriter.WriteMetadata(r.Context(), meta); err != nil {
s.deps.Log.Error("admin: apply description failed", "slug", req.Slug, "err", err)
jsonError(w, http.StatusInternalServerError, "write metadata: "+err.Error())
return
}
s.deps.Log.Info("admin: book description applied", "slug", req.Slug)
writeJSON(w, 0, map[string]any{"updated": true})
}

View File

@@ -73,6 +73,15 @@ type Dependencies struct {
// CFAI is the Cloudflare Workers AI TTS client (used for voice sample
// generation and audio-stream live TTS; audio task generation is done by the runner).
CFAI cfai.Client
// ImageGen is the Cloudflare Workers AI image generation client.
// If nil, image generation endpoints return 503.
ImageGen cfai.ImageGenClient
// TextGen is the Cloudflare Workers AI text generation client.
// If nil, text generation endpoints return 503.
TextGen cfai.TextGenClient
// BookWriter writes book metadata and chapter refs to PocketBase.
// Used by admin text-gen apply endpoints.
BookWriter bookstore.BookWriter
// Log is the structured logger.
Log *slog.Logger
}
@@ -183,6 +192,31 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
mux.HandleFunc("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
// Admin image generation endpoints
mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels)
mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen)
mux.HandleFunc("POST /api/admin/image-gen/save-cover", s.handleAdminImageGenSaveCover)
// Admin text generation endpoints (chapter names + book description)
mux.HandleFunc("GET /api/admin/text-gen/models", s.handleAdminTextGenModels)
mux.HandleFunc("POST /api/admin/text-gen/chapter-names", s.handleAdminTextGenChapterNames)
mux.HandleFunc("POST /api/admin/text-gen/chapter-names/apply", s.handleAdminTextGenApplyChapterNames)
mux.HandleFunc("POST /api/admin/text-gen/description", s.handleAdminTextGenDescription)
mux.HandleFunc("POST /api/admin/text-gen/description/apply", s.handleAdminTextGenApplyDescription)
// Admin catalogue enrichment endpoints
mux.HandleFunc("POST /api/admin/text-gen/tagline", s.handleAdminTextGenTagline)
mux.HandleFunc("POST /api/admin/text-gen/genres", s.handleAdminTextGenGenres)
mux.HandleFunc("POST /api/admin/text-gen/genres/apply", s.handleAdminTextGenApplyGenres)
mux.HandleFunc("POST /api/admin/text-gen/content-warnings", s.handleAdminTextGenContentWarnings)
mux.HandleFunc("POST /api/admin/text-gen/quality-score", s.handleAdminTextGenQualityScore)
mux.HandleFunc("POST /api/admin/catalogue/batch-covers", s.handleAdminBatchCovers)
mux.HandleFunc("POST /api/admin/catalogue/batch-covers/cancel", s.handleAdminBatchCoversCancel)
mux.HandleFunc("POST /api/admin/catalogue/refresh-metadata/{slug}", s.handleAdminRefreshMetadata)
// Admin data repair endpoints
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
// Voices list
mux.HandleFunc("GET /api/voices", s.handleVoices)

View File

@@ -35,6 +35,11 @@ type BookWriter interface {
// ChapterExists returns true if the markdown object for ref already exists.
ChapterExists(ctx context.Context, slug string, ref domain.ChapterRef) bool
// DeduplicateChapters removes duplicate chapters_idx records for slug,
// keeping only one record per chapter number (the one with the latest
// updated timestamp). Returns the number of duplicate records deleted.
DeduplicateChapters(ctx context.Context, slug string) (int, error)
}
// BookReader is the read side used by the backend to serve content.

View File

@@ -39,8 +39,9 @@ func (m *mockStore) ReadChapter(_ context.Context, _ string, _ int) (string, err
func (m *mockStore) ListChapters(_ context.Context, _ string) ([]domain.ChapterInfo, error) {
return nil, nil
}
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
func (m *mockStore) CountChapters(_ context.Context, _ string) int { return 0 }
func (m *mockStore) ReindexChapters(_ context.Context, _ string) (int, error) { return 0, nil }
func (m *mockStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
// RankingStore
func (m *mockStore) WriteRankingItem(_ context.Context, _ domain.RankingItem) error { return nil }
@@ -52,10 +53,10 @@ func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool
}
// AudioStore
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
return nil
}

View File

@@ -17,6 +17,10 @@
// response. There is no 100-second Cloudflare proxy timeout because we are
// calling the Cloudflare API directly, not routing through a Cloudflare-proxied
// homelab tunnel.
//
// The aura-2-en model enforces a hard 2 000-character limit per request.
// GenerateAudio transparently splits longer texts into sentence-boundary chunks
// and concatenates the resulting MP3 frames.
package cfai
import (
@@ -145,6 +149,8 @@ func New(accountID, apiToken, model string) Client {
}
// GenerateAudio calls the Cloudflare Workers AI TTS endpoint and returns MP3 bytes.
// The aura-2-en model rejects inputs longer than 2 000 characters, so this method
// splits the text into sentence-bounded chunks and concatenates the MP3 responses.
func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("cfai: empty text")
@@ -154,6 +160,20 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
speaker = "luna"
}
chunks := splitText(text, 1800) // stay comfortably under the 2 000-char limit
var combined []byte
for _, chunk := range chunks {
part, err := c.generateChunk(ctx, chunk, speaker)
if err != nil {
return nil, err
}
combined = append(combined, part...)
}
return combined, nil
}
// generateChunk sends a single ≤2 000-character request and returns MP3 bytes.
func (c *httpClient) generateChunk(ctx context.Context, text, speaker string) ([]byte, error) {
body, err := json.Marshal(map[string]any{
"text": text,
"speaker": speaker,
@@ -189,6 +209,87 @@ func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]b
return mp3, nil
}
// splitText splits src into chunks of at most maxChars characters each.
// It tries to break at paragraph boundaries first, then at sentence-ending
// punctuation (. ! ?), and falls back to the nearest space.
func splitText(src string, maxChars int) []string {
if len(src) <= maxChars {
return []string{src}
}
var chunks []string
remaining := src
for len(remaining) > 0 {
if len(remaining) <= maxChars {
chunks = append(chunks, strings.TrimSpace(remaining))
break
}
// Search window: the first maxChars bytes of remaining.
// Use byte length here because the API limit is in bytes/chars for ASCII;
// for safety we operate on rune-aware slices.
window := remaining
if len(window) > maxChars {
// Trim to maxChars runes (not bytes), ensuring we don't split a multi-byte char.
window = runeSlice(remaining, maxChars)
}
cut := -1
// 1. Prefer paragraph break (\n\n or \n).
if i := strings.LastIndex(window, "\n\n"); i > 0 {
cut = i + 2
} else if i := strings.LastIndex(window, "\n"); i > 0 {
cut = i + 1
}
// 2. Fall back to sentence-ending punctuation followed by a space.
if cut < 0 {
for _, punct := range []string{". ", "! ", "? ", ".\n", "!\n", "?\n"} {
if i := strings.LastIndex(window, punct); i > 0 {
candidate := i + len(punct)
if cut < 0 || candidate > cut {
cut = candidate
}
}
}
}
// 3. Last resort: nearest space.
if cut < 0 {
if i := strings.LastIndex(window, " "); i > 0 {
cut = i + 1
}
}
// 4. Hard cut at maxChars runes if no boundary found.
if cut < 0 {
cut = len(window)
}
chunk := strings.TrimSpace(remaining[:cut])
if chunk != "" {
chunks = append(chunks, chunk)
}
remaining = remaining[cut:]
}
return chunks
}
// runeSlice returns the first n runes of s as a string.
func runeSlice(s string, n int) string {
count := 0
for i := range s {
if count == n {
return s[:i]
}
count++
}
return s
}
// StreamAudioMP3 generates audio and wraps the MP3 bytes as an io.ReadCloser.
func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error) {
mp3, err := c.GenerateAudio(ctx, text, voice)

View File

@@ -0,0 +1,378 @@
// Image generation via Cloudflare Workers AI text-to-image models.
//
// API reference:
//
// POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model}
// Authorization: Bearer {apiToken}
// Content-Type: application/json
//
// Text-only request (all models):
//
// { "prompt": "...", "num_steps": 20 }
//
// Reference-image request:
// - FLUX models: { "prompt": "...", "image_b64": "<base64>" }
// - SD img2img: { "prompt": "...", "image": [r,g,b,a,...], "strength": 0.75 }
//
// All models return raw PNG bytes on success (Content-Type: image/png).
//
// Recommended models for LibNovel:
// - Book covers (no reference): flux-2-dev, flux-2-klein-9b, lucid-origin
// - Chapter images (speed): flux-2-klein-4b, flux-1-schnell
// - With reference image: flux-2-dev, flux-2-klein-9b, sd-v1-5-img2img
package cfai
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image/draw"
"image/jpeg"
_ "image/jpeg" // register JPEG decoder
"image/png"
_ "image/png" // register PNG decoder
"io"
"net/http"
"time"
)
// ImageModel identifies a Cloudflare Workers AI text-to-image model.
type ImageModel string
const (
// ImageModelFlux2Dev — best quality, multi-reference. Recommended for covers.
ImageModelFlux2Dev ImageModel = "@cf/black-forest-labs/flux-2-dev"
// ImageModelFlux2Klein9B — 9B params, multi-reference. Good for covers.
ImageModelFlux2Klein9B ImageModel = "@cf/black-forest-labs/flux-2-klein-9b"
// ImageModelFlux2Klein4B — ultra-fast, unified gen+edit. Recommended for chapters.
ImageModelFlux2Klein4B ImageModel = "@cf/black-forest-labs/flux-2-klein-4b"
// ImageModelFlux1Schnell — fastest, text-only. Good for quick illustrations.
ImageModelFlux1Schnell ImageModel = "@cf/black-forest-labs/flux-1-schnell"
// ImageModelSDXLLightning — fast 1024px generation.
ImageModelSDXLLightning ImageModel = "@cf/bytedance/stable-diffusion-xl-lightning"
// ImageModelSD15Img2Img — explicit img2img with flat RGBA reference.
ImageModelSD15Img2Img ImageModel = "@cf/runwayml/stable-diffusion-v1-5-img2img"
// ImageModelSDXLBase — Stability AI SDXL base.
ImageModelSDXLBase ImageModel = "@cf/stabilityai/stable-diffusion-xl-base-1.0"
// ImageModelLucidOrigin — Leonardo AI; strong prompt adherence.
ImageModelLucidOrigin ImageModel = "@cf/leonardo/lucid-origin"
// ImageModelPhoenix10 — Leonardo AI; accurate text rendering.
ImageModelPhoenix10 ImageModel = "@cf/leonardo/phoenix-1.0"
// DefaultImageModel is the default model for book-cover generation.
DefaultImageModel = ImageModelFlux2Dev
)
// ImageModelInfo describes a single image generation model.
type ImageModelInfo struct {
ID string `json:"id"`
Label string `json:"label"`
Provider string `json:"provider"`
SupportsRef bool `json:"supports_ref"`
RecommendedFor []string `json:"recommended_for"` // "cover" and/or "chapter"
Description string `json:"description"`
}
// AllImageModels returns metadata about every supported image model.
func AllImageModels() []ImageModelInfo {
return []ImageModelInfo{
{
ID: string(ImageModelFlux2Dev), Label: "FLUX.2 Dev", Provider: "Black Forest Labs",
SupportsRef: true, RecommendedFor: []string{"cover"},
Description: "Best quality; multi-reference editing. Recommended for book covers.",
},
{
ID: string(ImageModelFlux2Klein9B), Label: "FLUX.2 Klein 9B", Provider: "Black Forest Labs",
SupportsRef: true, RecommendedFor: []string{"cover"},
Description: "9B parameters with multi-reference support.",
},
{
ID: string(ImageModelFlux2Klein4B), Label: "FLUX.2 Klein 4B", Provider: "Black Forest Labs",
SupportsRef: true, RecommendedFor: []string{"chapter"},
Description: "Ultra-fast unified gen+edit. Recommended for chapter images.",
},
{
ID: string(ImageModelFlux1Schnell), Label: "FLUX.1 Schnell", Provider: "Black Forest Labs",
SupportsRef: false, RecommendedFor: []string{"chapter"},
Description: "Fastest inference. Good for quick chapter illustrations.",
},
{
ID: string(ImageModelSDXLLightning), Label: "SDXL Lightning", Provider: "ByteDance",
SupportsRef: false, RecommendedFor: []string{"chapter"},
Description: "Lightning-fast 1024px images in a few steps.",
},
{
ID: string(ImageModelSD15Img2Img), Label: "SD 1.5 img2img", Provider: "RunwayML",
SupportsRef: true, RecommendedFor: []string{"cover", "chapter"},
Description: "Explicit img2img: generates from a reference image + prompt.",
},
{
ID: string(ImageModelSDXLBase), Label: "SDXL Base 1.0", Provider: "Stability AI",
SupportsRef: false, RecommendedFor: []string{"cover"},
Description: "Stable Diffusion XL base model.",
},
{
ID: string(ImageModelLucidOrigin), Label: "Lucid Origin", Provider: "Leonardo AI",
SupportsRef: false, RecommendedFor: []string{"cover"},
Description: "Highly prompt-responsive; strong graphic design and HD renders.",
},
{
ID: string(ImageModelPhoenix10), Label: "Phoenix 1.0", Provider: "Leonardo AI",
SupportsRef: false, RecommendedFor: []string{"cover"},
Description: "Exceptional prompt adherence; accurate text rendering.",
},
}
}
// ImageRequest is the input to GenerateImage / GenerateImageFromReference.
type ImageRequest struct {
// Prompt is the text description of the desired image.
Prompt string
// Model is the CF Workers AI model. Defaults to DefaultImageModel when empty.
Model ImageModel
// NumSteps controls inference quality (default 20). Range: 120.
NumSteps int
// Width and Height in pixels. 0 = model default (typically 1024x1024).
Width, Height int
// Guidance controls prompt adherence (default 7.5).
Guidance float64
// Strength for img2img: 0.0 = copy reference, 1.0 = ignore reference (default 0.75).
Strength float64
}
// ImageGenClient generates images via Cloudflare Workers AI.
type ImageGenClient interface {
// GenerateImage creates an image from a text prompt only.
// Returns raw PNG bytes.
GenerateImage(ctx context.Context, req ImageRequest) ([]byte, error)
// GenerateImageFromReference creates an image from a text prompt + reference image.
// refImage should be PNG or JPEG bytes. Returns raw PNG bytes.
GenerateImageFromReference(ctx context.Context, req ImageRequest, refImage []byte) ([]byte, error)
// Models returns metadata about all supported image models.
Models() []ImageModelInfo
}
// imageGenHTTPClient is the concrete CF AI image generation client.
type imageGenHTTPClient struct {
accountID string
apiToken string
http *http.Client
}
// NewImageGen returns an ImageGenClient for the given Cloudflare account.
func NewImageGen(accountID, apiToken string) ImageGenClient {
return &imageGenHTTPClient{
accountID: accountID,
apiToken: apiToken,
http: &http.Client{Timeout: 5 * time.Minute},
}
}
// GenerateImage generates an image from text only.
func (c *imageGenHTTPClient) GenerateImage(ctx context.Context, req ImageRequest) ([]byte, error) {
req = applyImageDefaults(req)
body := map[string]any{
"prompt": req.Prompt,
"num_steps": req.NumSteps,
}
if req.Width > 0 {
body["width"] = req.Width
}
if req.Height > 0 {
body["height"] = req.Height
}
if req.Guidance > 0 {
body["guidance"] = req.Guidance
}
return c.callImageAPI(ctx, req.Model, body)
}
// refImageMaxDim is the maximum dimension (width or height) for reference images
// sent to Cloudflare Workers AI. CF's JSON body limit is ~4 MB; a 768px JPEG
// stays well under that while preserving enough detail for img2img guidance.
const refImageMaxDim = 768
// GenerateImageFromReference generates an image from a text prompt + reference image.
func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req ImageRequest, refImage []byte) ([]byte, error) {
if len(refImage) == 0 {
return c.GenerateImage(ctx, req)
}
req = applyImageDefaults(req)
// Shrink the reference image if it exceeds the safe payload size.
// This avoids CF's 4 MB JSON body limit and reduces latency.
refImage = resizeRefImage(refImage, refImageMaxDim)
var body map[string]any
if req.Model == ImageModelSD15Img2Img {
pixels, err := decodeImageToRGBA(refImage)
if err != nil {
return nil, fmt.Errorf("cfai/image: decode reference: %w", err)
}
strength := req.Strength
if strength <= 0 {
strength = 0.75
}
body = map[string]any{
"prompt": req.Prompt,
"image": pixels,
"strength": strength,
"num_steps": req.NumSteps,
}
} else {
b64 := base64.StdEncoding.EncodeToString(refImage)
body = map[string]any{
"prompt": req.Prompt,
"image_b64": b64,
"num_steps": req.NumSteps,
}
if req.Strength > 0 {
body["strength"] = req.Strength
}
}
if req.Width > 0 {
body["width"] = req.Width
}
if req.Height > 0 {
body["height"] = req.Height
}
if req.Guidance > 0 {
body["guidance"] = req.Guidance
}
return c.callImageAPI(ctx, req.Model, body)
}
// Models returns all supported image model metadata.
func (c *imageGenHTTPClient) Models() []ImageModelInfo {
return AllImageModels()
}
func (c *imageGenHTTPClient) callImageAPI(ctx context.Context, model ImageModel, body map[string]any) ([]byte, error) {
encoded, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("cfai/image: marshal: %w", err)
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
c.accountID, string(model))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(encoded))
if err != nil {
return nil, fmt.Errorf("cfai/image: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("cfai/image: http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(resp.Body)
msg := string(errBody)
if len(msg) > 300 {
msg = msg[:300]
}
return nil, fmt.Errorf("cfai/image: model %s returned %d: %s", model, resp.StatusCode, msg)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cfai/image: read response: %w", err)
}
return data, nil
}
func applyImageDefaults(req ImageRequest) ImageRequest {
if req.Model == "" {
req.Model = DefaultImageModel
}
if req.NumSteps <= 0 {
req.NumSteps = 20
}
return req
}
// resizeRefImage down-scales an image so that its longest side is at most maxDim
// pixels, then re-encodes it as JPEG (quality 85). If the image is already small
// enough, or if decoding fails, the original bytes are returned unchanged.
// This keeps the JSON payload well under Cloudflare Workers AI's 4 MB body limit.
func resizeRefImage(data []byte, maxDim int) []byte {
src, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return data
}
b := src.Bounds()
w, h := b.Dx(), b.Dy()
longest := w
if h > longest {
longest = h
}
if longest <= maxDim {
return data // already fits
}
// Compute target dimensions preserving aspect ratio.
scale := float64(maxDim) / float64(longest)
newW := int(float64(w)*scale + 0.5)
newH := int(float64(h)*scale + 0.5)
if newW < 1 {
newW = 1
}
if newH < 1 {
newH = 1
}
// Nearest-neighbour downsample (no extra deps, sufficient for reference guidance).
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
for y := 0; y < newH; y++ {
for x := 0; x < newW; x++ {
srcX := b.Min.X + int(float64(x)/scale)
srcY := b.Min.Y + int(float64(y)/scale)
draw.Draw(dst, image.Rect(x, y, x+1, y+1), src, image.Pt(srcX, srcY), draw.Src)
}
}
var buf bytes.Buffer
if format == "jpeg" {
if encErr := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85}); encErr != nil {
return data
}
} else {
if encErr := png.Encode(&buf, dst); encErr != nil {
return data
}
}
return buf.Bytes()
}
// decodeImageToRGBA decodes PNG/JPEG bytes to a flat []uint8 RGBA pixel array
// required by the stable-diffusion-v1-5-img2img model.
func decodeImageToRGBA(data []byte) ([]uint8, error) {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("decode image: %w", err)
}
bounds := img.Bounds()
w := bounds.Max.X - bounds.Min.X
h := bounds.Max.Y - bounds.Min.Y
pixels := make([]uint8, w*h*4)
idx := 0
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
r, g, b, a := img.At(x, y).RGBA()
pixels[idx] = uint8(r >> 8)
pixels[idx+1] = uint8(g >> 8)
pixels[idx+2] = uint8(b >> 8)
pixels[idx+3] = uint8(a >> 8)
idx += 4
}
}
return pixels, nil
}

View File

@@ -0,0 +1,253 @@
// Text generation via Cloudflare Workers AI LLM models.
//
// API reference:
//
// POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model}
// Authorization: Bearer {apiToken}
// Content-Type: application/json
//
// Request body (all models):
//
// { "messages": [{"role":"system","content":"..."},{"role":"user","content":"..."}] }
//
// Response (wrapped):
//
// { "result": { "response": "..." }, "success": true }
package cfai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// TextModel identifies a Cloudflare Workers AI text generation model.
type TextModel string
const (
// TextModelGemma4 — Google Gemma 4, 256k context.
TextModelGemma4 TextModel = "@cf/google/gemma-4-26b-a4b-it"
// TextModelLlama4Scout — Meta Llama 4 Scout 17B, multimodal.
TextModelLlama4Scout TextModel = "@cf/meta/llama-4-scout-17b-16e-instruct"
// TextModelLlama33_70B — Meta Llama 3.3 70B, fast fp8.
TextModelLlama33_70B TextModel = "@cf/meta/llama-3.3-70b-instruct-fp8-fast"
// TextModelQwen3_30B — Qwen3 30B MoE, function calling.
TextModelQwen3_30B TextModel = "@cf/qwen/qwen3-30b-a3b-fp8"
// TextModelMistralSmall — Mistral Small 3.1 24B, 128k context.
TextModelMistralSmall TextModel = "@cf/mistralai/mistral-small-3.1-24b-instruct"
// TextModelQwQ32B — Qwen QwQ 32B reasoning model.
TextModelQwQ32B TextModel = "@cf/qwen/qwq-32b"
// TextModelDeepSeekR1 — DeepSeek R1 distill Qwen 32B.
TextModelDeepSeekR1 TextModel = "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b"
// TextModelGemma3_12B — Google Gemma 3 12B, 80k context.
TextModelGemma3_12B TextModel = "@cf/google/gemma-3-12b-it"
// TextModelGPTOSS120B — OpenAI gpt-oss-120b, high reasoning.
TextModelGPTOSS120B TextModel = "@cf/openai/gpt-oss-120b"
// TextModelGPTOSS20B — OpenAI gpt-oss-20b, lower latency.
TextModelGPTOSS20B TextModel = "@cf/openai/gpt-oss-20b"
// TextModelNemotron3 — NVIDIA Nemotron 3 120B, agentic.
TextModelNemotron3 TextModel = "@cf/nvidia/nemotron-3-120b-a12b"
// TextModelLlama32_3B — Meta Llama 3.2 3B, lightweight.
TextModelLlama32_3B TextModel = "@cf/meta/llama-3.2-3b-instruct"
// DefaultTextModel is the default model used when none is specified.
DefaultTextModel = TextModelLlama4Scout
)
// TextModelInfo describes a single text generation model.
type TextModelInfo struct {
ID string `json:"id"`
Label string `json:"label"`
Provider string `json:"provider"`
ContextSize int `json:"context_size"` // max context in tokens
Description string `json:"description"`
}
// AllTextModels returns metadata about every supported text generation model.
func AllTextModels() []TextModelInfo {
return []TextModelInfo{
{
ID: string(TextModelGemma4), Label: "Gemma 4 26B", Provider: "Google",
ContextSize: 256000,
Description: "Google's most intelligent open model family. 256k context, function calling.",
},
{
ID: string(TextModelLlama4Scout), Label: "Llama 4 Scout 17B", Provider: "Meta",
ContextSize: 131000,
Description: "Natively multimodal, 16 experts. Good all-purpose model with function calling.",
},
{
ID: string(TextModelLlama33_70B), Label: "Llama 3.3 70B (fp8 fast)", Provider: "Meta",
ContextSize: 24000,
Description: "Llama 3.3 70B quantized to fp8 for speed. Excellent instruction following.",
},
{
ID: string(TextModelQwen3_30B), Label: "Qwen3 30B MoE", Provider: "Qwen",
ContextSize: 32768,
Description: "MoE architecture with strong reasoning and instruction following.",
},
{
ID: string(TextModelMistralSmall), Label: "Mistral Small 3.1 24B", Provider: "MistralAI",
ContextSize: 128000,
Description: "Strong text performance with 128k context and function calling.",
},
{
ID: string(TextModelQwQ32B), Label: "QwQ 32B (reasoning)", Provider: "Qwen",
ContextSize: 24000,
Description: "Reasoning model — thinks before answering. Slower but more accurate.",
},
{
ID: string(TextModelDeepSeekR1), Label: "DeepSeek R1 32B", Provider: "DeepSeek",
ContextSize: 80000,
Description: "R1-distilled reasoning model. Outperforms o1-mini on many benchmarks.",
},
{
ID: string(TextModelGemma3_12B), Label: "Gemma 3 12B", Provider: "Google",
ContextSize: 80000,
Description: "Multimodal, 128k context, multilingual (140+ languages).",
},
{
ID: string(TextModelGPTOSS120B), Label: "GPT-OSS 120B", Provider: "OpenAI",
ContextSize: 128000,
Description: "OpenAI open-weight model for production, general purpose, high reasoning.",
},
{
ID: string(TextModelGPTOSS20B), Label: "GPT-OSS 20B", Provider: "OpenAI",
ContextSize: 128000,
Description: "OpenAI open-weight model for lower latency and specialized use cases.",
},
{
ID: string(TextModelNemotron3), Label: "Nemotron 3 120B", Provider: "NVIDIA",
ContextSize: 256000,
Description: "Hybrid MoE with leading accuracy for multi-agent applications.",
},
{
ID: string(TextModelLlama32_3B), Label: "Llama 3.2 3B", Provider: "Meta",
ContextSize: 80000,
Description: "Lightweight model for simple tasks. Fast and cheap.",
},
}
}
// TextMessage is a single message in a chat conversation.
type TextMessage struct {
Role string `json:"role"` // "system" or "user"
Content string `json:"content"` // message text
}
// TextRequest is the input to Generate.
type TextRequest struct {
// Model is the CF Workers AI model ID. Defaults to DefaultTextModel when empty.
Model TextModel
// Messages is the conversation history (system + user messages).
Messages []TextMessage
// MaxTokens limits the output length (0 = model default).
MaxTokens int
}
// TextGenClient generates text via Cloudflare Workers AI LLM models.
type TextGenClient interface {
// Generate sends a chat-style request and returns the model's response text.
Generate(ctx context.Context, req TextRequest) (string, error)
// Models returns metadata about all supported text generation models.
Models() []TextModelInfo
}
// textGenHTTPClient is the concrete CF AI text generation client.
type textGenHTTPClient struct {
accountID string
apiToken string
http *http.Client
}
// NewTextGen returns a TextGenClient for the given Cloudflare account.
func NewTextGen(accountID, apiToken string) TextGenClient {
return &textGenHTTPClient{
accountID: accountID,
apiToken: apiToken,
http: &http.Client{Timeout: 5 * time.Minute},
}
}
// Generate sends messages to the model and returns the response text.
func (c *textGenHTTPClient) Generate(ctx context.Context, req TextRequest) (string, error) {
if req.Model == "" {
req.Model = DefaultTextModel
}
body := map[string]any{
"messages": req.Messages,
}
if req.MaxTokens > 0 {
body["max_tokens"] = req.MaxTokens
}
encoded, err := json.Marshal(body)
if err != nil {
return "", fmt.Errorf("cfai/text: marshal: %w", err)
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
c.accountID, string(req.Model))
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(encoded))
if err != nil {
return "", fmt.Errorf("cfai/text: build request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+c.apiToken)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(httpReq)
if err != nil {
return "", fmt.Errorf("cfai/text: http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(resp.Body)
msg := string(errBody)
if len(msg) > 300 {
msg = msg[:300]
}
return "", fmt.Errorf("cfai/text: model %s returned %d: %s", req.Model, resp.StatusCode, msg)
}
// CF AI wraps responses: { "result": { "response": "..." }, "success": true }
// Some models (e.g. Llama 4 Scout) return response as an array:
// { "result": { "response": [{"generated_text":"..."}] } }
var wrapper struct {
Result struct {
Response json.RawMessage `json:"response"`
} `json:"result"`
Success bool `json:"success"`
Errors []string `json:"errors"`
}
if err := json.NewDecoder(resp.Body).Decode(&wrapper); err != nil {
return "", fmt.Errorf("cfai/text: decode response: %w", err)
}
if !wrapper.Success {
return "", fmt.Errorf("cfai/text: model %s error: %v", req.Model, wrapper.Errors)
}
// Try plain string first.
var text string
if err := json.Unmarshal(wrapper.Result.Response, &text); err == nil {
return text, nil
}
// Fall back: array of objects with a "generated_text" field.
var arr []struct {
GeneratedText string `json:"generated_text"`
}
if err := json.Unmarshal(wrapper.Result.Response, &arr); err == nil && len(arr) > 0 {
return arr[0].GeneratedText, nil
}
return "", fmt.Errorf("cfai/text: model %s: unrecognised response shape: %s", req.Model, wrapper.Result.Response)
}
// Models returns all supported text generation model metadata.
func (c *textGenHTTPClient) Models() []TextModelInfo {
return AllTextModels()
}

View File

@@ -89,6 +89,8 @@ func (s *stubStore) WriteChapterRefs(_ context.Context, _ string, _ []domain.Cha
return nil
}
func (s *stubStore) DeduplicateChapters(_ context.Context, _ string) (int, error) { return 0, nil }
func (s *stubStore) ChapterExists(_ context.Context, slug string, ref domain.ChapterRef) bool {
s.mu.Lock()
defer s.mu.Unlock()

View File

@@ -94,6 +94,10 @@ func (s *stubBookWriter) ChapterExists(_ context.Context, _ string, _ domain.Cha
return false
}
func (s *stubBookWriter) DeduplicateChapters(_ context.Context, _ string) (int, error) {
return 0, nil
}
// stubBookReader satisfies bookstore.BookReader — returns a single chapter.
type stubBookReader struct {
text string

View File

@@ -130,7 +130,23 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch
return err
}
if len(items) == 0 {
return s.pb.post(ctx, "/api/collections/chapters_idx/records", payload, nil)
// Set created timestamp on first insert so recentlyUpdatedBooks can sort by it.
insertPayload := map[string]any{
"slug": slug,
"number": ref.Number,
"title": ref.Title,
"created": time.Now().UTC().Format(time.RFC3339),
}
postErr := s.pb.post(ctx, "/api/collections/chapters_idx/records", insertPayload, nil)
if postErr == nil {
return nil
}
// POST failed — a concurrent writer may have inserted the same slug+number.
// Re-fetch and fall through to PATCH (mirrors WriteMetadata retry pattern).
items, err = s.pb.listAll(ctx, "chapters_idx", filter, "")
if err != nil || len(items) == 0 {
return postErr // original POST error is more informative
}
}
var rec struct {
ID string `json:"id"`
@@ -139,6 +155,59 @@ func (s *Store) upsertChapterIdx(ctx context.Context, slug string, ref domain.Ch
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID), payload)
}
// DeduplicateChapters removes duplicate chapters_idx records for slug.
// For each chapter number that has more than one record, it keeps the record
// with the latest "updated" timestamp and deletes the rest.
// Returns the number of records deleted.
func (s *Store) DeduplicateChapters(ctx context.Context, slug string) (int, error) {
filter := fmt.Sprintf(`slug=%q`, slug)
items, err := s.pb.listAll(ctx, "chapters_idx", filter, "number")
if err != nil {
return 0, fmt.Errorf("DeduplicateChapters: list: %w", err)
}
type record struct {
ID string `json:"id"`
Number int `json:"number"`
Updated string `json:"updated"`
}
// Group records by chapter number.
byNumber := make(map[int][]record)
for _, raw := range items {
var rec record
if err := json.Unmarshal(raw, &rec); err != nil || rec.ID == "" {
continue
}
byNumber[rec.Number] = append(byNumber[rec.Number], rec)
}
deleted := 0
for _, recs := range byNumber {
if len(recs) <= 1 {
continue
}
// Keep the record with the latest Updated timestamp; delete the rest.
keep := 0
for i := 1; i < len(recs); i++ {
if recs[i].Updated > recs[keep].Updated {
keep = i
}
}
for i, rec := range recs {
if i == keep {
continue
}
if delErr := s.pb.delete(ctx, fmt.Sprintf("/api/collections/chapters_idx/records/%s", rec.ID)); delErr != nil {
s.log.Warn("DeduplicateChapters: delete failed", "slug", slug, "number", rec.Number, "id", rec.ID, "err", delErr)
continue
}
deleted++
}
}
return deleted, nil
}
// ── BookReader ────────────────────────────────────────────────────────────────
type pbBook struct {

View File

@@ -19,6 +19,9 @@ x-infra-env: &infra-env
MEILI_API_KEY: "${MEILI_MASTER_KEY}"
# Valkey
VALKEY_ADDR: "valkey:6379"
# Cloudflare AI (TTS + image generation)
CFAI_ACCOUNT_ID: "${CFAI_ACCOUNT_ID}"
CFAI_API_TOKEN: "${CFAI_API_TOKEN}"
services:
# ─── MinIO (object storage: chapters, audio, avatars, browse) ────────────────
@@ -183,7 +186,7 @@ services:
KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}"
POCKET_TTS_URL: "${POCKET_TTS_URL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN_BACKEND}"
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
OTEL_SERVICE_NAME: "backend"
# Asynq task queue — backend enqueues jobs to local Redis sidecar.
@@ -246,7 +249,7 @@ services:
KOKORO_URL: "${KOKORO_URL}"
KOKORO_VOICE: "${KOKORO_VOICE}"
POCKET_TTS_URL: "${POCKET_TTS_URL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}"
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
OTEL_SERVICE_NAME: "runner"
healthcheck:
@@ -299,6 +302,8 @@ services:
PUBLIC_UMAMI_SCRIPT_URL: "${PUBLIC_UMAMI_SCRIPT_URL}"
# GlitchTip client + server-side error tracking
PUBLIC_GLITCHTIP_DSN: "${PUBLIC_GLITCHTIP_DSN}"
# Grafana Faro RUM (browser Web Vitals, traces, errors)
PUBLIC_FARO_COLLECTOR_URL: "${PUBLIC_FARO_COLLECTOR_URL}"
# OpenTelemetry tracing
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
OTEL_SERVICE_NAME: "ui"
@@ -307,6 +312,9 @@ services:
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
GITHUB_CLIENT_ID: "${GITHUB_CLIENT_ID}"
GITHUB_CLIENT_SECRET: "${GITHUB_CLIENT_SECRET}"
# Polar (subscriptions)
POLAR_API_TOKEN: "${POLAR_API_TOKEN}"
POLAR_WEBHOOK_SECRET: "${POLAR_WEBHOOK_SECRET}"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
interval: 15s

View File

@@ -18,6 +18,7 @@
# uptime.libnovel.cc → uptime-kuma:3001
# push.libnovel.cc → gotify:80
# grafana.libnovel.cc → grafana:3000
# faro.libnovel.cc → alloy:12347
services:
@@ -81,7 +82,7 @@ services:
RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH: "true"
LOG_LEVEL: "${LOG_LEVEL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}"
# OTel — send runner traces/metrics to the local collector (HTTP)
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector:4318"
@@ -346,6 +347,23 @@ services:
timeout: 5s
retries: 5
# ── Grafana Alloy (Faro RUM receiver) ───────────────────────────────────────
# Receives browser telemetry from @grafana/faro-web-sdk (Web Vitals, traces,
# errors). Exposes POST /collect at faro.libnovel.cc via cloudflared.
# Forwards traces to otel-collector (→ Tempo) and logs to Loki directly.
alloy:
image: grafana/alloy:latest
restart: unless-stopped
command: ["run", "--server.http.listen-addr=0.0.0.0:12348", "/etc/alloy/alloy.river"]
volumes:
- ./otel/alloy.river:/etc/alloy/alloy.river:ro
expose:
- "12347" # Faro HTTP receiver (POST /collect)
- "12348" # Alloy UI / health endpoint
depends_on:
- otel-collector
- loki
# ── OTel Collector ──────────────────────────────────────────────────────────
# Receives OTLP from backend/ui/runner, fans out to Tempo + Prometheus + Loki.
otel-collector:

43
homelab/otel/alloy.river Normal file
View File

@@ -0,0 +1,43 @@
// Grafana Alloy — Faro RUM receiver
//
// Receives browser telemetry (Web Vitals, traces, logs, exceptions) from the
// LibNovel SvelteKit frontend via the @grafana/faro-web-sdk.
//
// Pipeline:
// faro.receiver → receives HTTP POST /collect from browsers
// otelcol.exporter.otlphttp → forwards traces to OTel Collector → Tempo
// loki.write → forwards logs/exceptions to Loki
//
// The Faro endpoint is exposed publicly at faro.libnovel.cc via cloudflared.
// CORS is configured to allow requests from libnovel.cc.
faro.receiver "faro" {
server {
listen_address = "0.0.0.0"
listen_port = 12347
cors_allowed_origins = ["https://libnovel.cc", "https://www.libnovel.cc"]
}
output {
logs = [loki.write.faro.receiver]
traces = [otelcol.exporter.otlphttp.faro.input]
}
}
// Forward Faro traces to the OTel Collector (which routes to Tempo)
otelcol.exporter.otlphttp "faro" {
client {
endpoint = "http://otel-collector:4318"
tls {
insecure = true
}
}
}
// Forward Faro logs/exceptions directly to Loki
loki.write "faro" {
endpoint {
url = "http://loki:3100/loki/api/v1/push"
}
}

View File

@@ -53,6 +53,9 @@ extensions:
service:
extensions: [health_check, pprof]
telemetry:
metrics:
address: 0.0.0.0:8888
pipelines:
traces:
receivers: [otlp]

View File

@@ -1,7 +1,7 @@
{
"uid": "libnovel-backend",
"title": "Backend API",
"description": "Request rate, error rate, and latency for the LibNovel backend. Powered by Tempo span metrics and UI OTel instrumentation.",
"description": "Request rate, error rate, and latency for the LibNovel backend. Powered by Tempo span metrics.",
"tags": ["libnovel", "backend", "api"],
"timezone": "browser",
"refresh": "30s",
@@ -173,7 +173,7 @@
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"5..\"}[5m])) * 60",
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\", status_code=\"STATUS_CODE_ERROR\"}[5m])) * 60",
"legendFormat": "5xx/min",
"instant": true
}
@@ -182,7 +182,7 @@
{
"id": 10,
"type": "timeseries",
"title": "Request Rate by Status",
"title": "Request Rate (total vs errors)",
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
"options": {
"tooltip": { "mode": "multi" },
@@ -191,27 +191,21 @@
"fieldConfig": {
"defaults": { "unit": "reqps", "custom": { "lineWidth": 2, "fillOpacity": 10 } },
"overrides": [
{ "matcher": { "id": "byFrameRefID", "options": "errors" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
{ "matcher": { "id": "byName", "options": "errors" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }] }
]
},
"targets": [
{
"refId": "success",
"refId": "total",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"2..\"}[5m]))",
"legendFormat": "2xx"
},
{
"refId": "notfound",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"4..\"}[5m]))",
"legendFormat": "4xx"
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\"}[5m]))",
"legendFormat": "total"
},
{
"refId": "errors",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\", http_response_status_code=~\"5..\"}[5m]))",
"legendFormat": "5xx"
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"backend\", status_code=\"STATUS_CODE_ERROR\"}[5m]))",
"legendFormat": "errors"
}
]
},
@@ -248,50 +242,30 @@
{
"id": 12,
"type": "timeseries",
"title": "Requests / min by HTTP method (UI → Backend)",
"title": "Request Rate by Span Name (top operations)",
"gridPos": { "x": 0, "y": 12, "w": 12, "h": 8 },
"description": "Throughput broken down by HTTP route / span name from Tempo span metrics.",
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 5 } }
"defaults": { "unit": "reqps", "custom": { "lineWidth": 2, "fillOpacity": 5 } }
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"backend\"}[5m])) by (http_request_method) * 60",
"legendFormat": "{{http_request_method}}"
"expr": "topk(10, sum(rate(traces_spanmetrics_calls_total{service=\"backend\"}[5m])) by (span_name))",
"legendFormat": "{{span_name}}"
}
]
},
{
"id": 13,
"type": "timeseries",
"title": "Requests / min — UI → PocketBase",
"title": "Latency by Span Name (p95)",
"gridPos": { "x": 12, "y": 12, "w": 12, "h": 8 },
"description": "Traffic from SvelteKit server to PocketBase (auth, collections, etc.).",
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
},
"fieldConfig": {
"defaults": { "unit": "short", "custom": { "lineWidth": 2, "fillOpacity": 5 } }
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(http_client_request_duration_seconds_count{job=\"ui\", server_address=\"pocketbase\"}[5m])) by (http_request_method, http_response_status_code) * 60",
"legendFormat": "{{http_request_method}} {{http_response_status_code}}"
}
]
},
{
"id": 14,
"type": "timeseries",
"title": "UI → Backend Latency (p50 / p95)",
"gridPos": { "x": 0, "y": 20, "w": 12, "h": 8 },
"description": "HTTP client latency as seen from the SvelteKit SSR layer calling backend.",
"description": "p95 latency per operation — helps identify slow endpoints.",
"options": {
"tooltip": { "mode": "multi" },
"legend": { "displayMode": "list", "placement": "bottom" }
@@ -302,13 +276,8 @@
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.50, sum(rate(http_client_request_duration_seconds_bucket{job=\"ui\", server_address=\"backend\"}[5m])) by (le))",
"legendFormat": "p50"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.95, sum(rate(http_client_request_duration_seconds_bucket{job=\"ui\", server_address=\"backend\"}[5m])) by (le))",
"legendFormat": "p95"
"expr": "topk(10, histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"backend\"}[5m])) by (le, span_name)))",
"legendFormat": "{{span_name}}"
}
]
},
@@ -316,7 +285,7 @@
"id": 20,
"type": "logs",
"title": "Backend Errors",
"gridPos": { "x": 0, "y": 28, "w": 24, "h": 10 },
"gridPos": { "x": 0, "y": 20, "w": 24, "h": 10 },
"options": {
"showTime": true,
"showLabels": false,

View File

@@ -35,7 +35,7 @@
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_tasks_running",
"expr": "runner_tasks_running",
"legendFormat": "running",
"instant": true
}
@@ -61,7 +61,7 @@
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_tasks_completed_total",
"expr": "runner_tasks_completed_total",
"legendFormat": "completed",
"instant": true
}
@@ -93,7 +93,7 @@
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_tasks_failed_total",
"expr": "runner_tasks_failed_total",
"legendFormat": "failed",
"instant": true
}
@@ -126,7 +126,7 @@
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_uptime_seconds",
"expr": "runner_uptime_seconds",
"legendFormat": "uptime",
"instant": true
}
@@ -159,7 +159,7 @@
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_tasks_failed_total / clamp_min(libnovel_runner_tasks_completed_total + libnovel_runner_tasks_failed_total, 1)",
"expr": "runner_tasks_failed_total / clamp_min(runner_tasks_completed_total + runner_tasks_failed_total, 1)",
"legendFormat": "failure rate",
"instant": true
}
@@ -215,17 +215,17 @@
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "rate(libnovel_runner_tasks_completed_total[5m]) * 60",
"expr": "rate(runner_tasks_completed_total[5m]) * 60",
"legendFormat": "completed"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "rate(libnovel_runner_tasks_failed_total[5m]) * 60",
"expr": "rate(runner_tasks_failed_total[5m]) * 60",
"legendFormat": "failed"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "libnovel_runner_tasks_running",
"expr": "runner_tasks_running",
"legendFormat": "running"
}
]

View File

@@ -0,0 +1,284 @@
{
"uid": "libnovel-web-vitals",
"title": "Web Vitals (RUM)",
"description": "Real User Monitoring — Core Web Vitals (LCP, CLS, INP, TTFB, FCP) from @grafana/faro-web-sdk. Data flows: browser → Alloy faro.receiver → Tempo (traces) + Loki (logs).",
"tags": ["libnovel", "frontend", "rum", "web-vitals"],
"timezone": "browser",
"refresh": "1m",
"time": { "from": "now-24h", "to": "now" },
"schemaVersion": 39,
"panels": [
{
"id": 1,
"type": "stat",
"title": "LCP — p75 (Largest Contentful Paint)",
"description": "Good < 2.5 s, needs improvement < 4 s, poor ≥ 4 s.",
"gridPos": { "x": 0, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"unit": "ms",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 2500 },
{ "color": "red", "value": 4000 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[1h])) by (le)) * 1000",
"legendFormat": "LCP p75",
"instant": true
}
]
},
{
"id": 2,
"type": "stat",
"title": "INP — p75 (Interaction to Next Paint)",
"description": "Good < 200 ms, needs improvement < 500 ms, poor ≥ 500 ms.",
"gridPos": { "x": 4, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"unit": "ms",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 200 },
{ "color": "red", "value": 500 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*inp|INP\"}[1h])) by (le)) * 1000",
"legendFormat": "INP p75",
"instant": true
}
]
},
{
"id": 3,
"type": "stat",
"title": "CLS — p75 (Cumulative Layout Shift)",
"description": "Good < 0.1, needs improvement < 0.25, poor ≥ 0.25.",
"gridPos": { "x": 8, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"unit": "short",
"decimals": 3,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.1 },
{ "color": "red", "value": 0.25 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*cls|CLS\"}[1h])) by (le))",
"legendFormat": "CLS p75",
"instant": true
}
]
},
{
"id": 4,
"type": "stat",
"title": "TTFB — p75 (Time to First Byte)",
"description": "Good < 800 ms, needs improvement < 1800 ms, poor ≥ 1800 ms.",
"gridPos": { "x": 12, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"unit": "ms",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 800 },
{ "color": "red", "value": 1800 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[1h])) by (le)) * 1000",
"legendFormat": "TTFB p75",
"instant": true
}
]
},
{
"id": 5,
"type": "stat",
"title": "FCP — p75 (First Contentful Paint)",
"description": "Good < 1.8 s, needs improvement < 3 s, poor ≥ 3 s.",
"gridPos": { "x": 16, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background", "graphMode": "none" },
"fieldConfig": {
"defaults": {
"unit": "ms",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1800 },
{ "color": "red", "value": 3000 }
]
}
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*fcp|FCP\"}[1h])) by (le)) * 1000",
"legendFormat": "FCP p75",
"instant": true
}
]
},
{
"id": 6,
"type": "stat",
"title": "Active Sessions (30 min)",
"gridPos": { "x": 20, "y": 0, "w": 4, "h": 4 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "value", "graphMode": "area" },
"fieldConfig": {
"defaults": {
"unit": "short",
"thresholds": { "mode": "absolute", "steps": [{ "color": "green", "value": null }] }
}
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum(rate(traces_spanmetrics_calls_total{service=\"libnovel-ui\"}[30m]))",
"legendFormat": "sessions",
"instant": true
}
]
},
{
"id": 10,
"type": "timeseries",
"title": "LCP over time (p50 / p75 / p95)",
"gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 },
"options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } },
"fieldConfig": {
"defaults": { "unit": "ms", "custom": { "lineWidth": 2, "fillOpacity": 10 } },
"overrides": [
{ "matcher": { "id": "byName", "options": "Good (2.5s)" }, "properties": [{ "id": "color", "value": { "fixedColor": "green", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [4, 4] } }] },
{ "matcher": { "id": "byName", "options": "Poor (4s)" }, "properties": [{ "id": "color", "value": { "fixedColor": "red", "mode": "fixed" } }, { "id": "custom.lineStyle", "value": { "fill": "dash", "dash": [4, 4] } }] }
]
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.50, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000",
"legendFormat": "p50"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000",
"legendFormat": "p75"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*lcp|LCP\"}[5m])) by (le)) * 1000",
"legendFormat": "p95"
},
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "2500", "legendFormat": "Good (2.5s)" },
{ "datasource": { "type": "prometheus", "uid": "prometheus" }, "expr": "4000", "legendFormat": "Poor (4s)" }
]
},
{
"id": 11,
"type": "timeseries",
"title": "TTFB over time (p50 / p75 / p95)",
"gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 },
"options": { "tooltip": { "mode": "multi" }, "legend": { "displayMode": "list", "placement": "bottom" } },
"fieldConfig": {
"defaults": { "unit": "ms", "custom": { "lineWidth": 2, "fillOpacity": 10 } }
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.50, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000",
"legendFormat": "p50"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.75, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000",
"legendFormat": "p75"
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "histogram_quantile(0.95, sum(rate(traces_spanmetrics_latency_bucket{service=\"libnovel-ui\", span_name=~\"faro.*ttfb|TTFB\"}[5m])) by (le)) * 1000",
"legendFormat": "p95"
}
]
},
{
"id": 20,
"type": "logs",
"title": "Frontend Errors & Exceptions",
"description": "JS exceptions and console errors captured by Faro and shipped to Loki.",
"gridPos": { "x": 0, "y": 12, "w": 24, "h": 10 },
"options": {
"showTime": true,
"showLabels": true,
"wrapLogMessage": true,
"prettifyLogMessage": true,
"enableLogDetails": true,
"sortOrder": "Descending",
"dedupStrategy": "none"
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"libnovel-ui\"} | json | kind =~ `(exception|error)`",
"legendFormat": ""
}
]
},
{
"id": 21,
"type": "logs",
"title": "Frontend Logs (all Faro events)",
"gridPos": { "x": 0, "y": 22, "w": 24, "h": 10 },
"options": {
"showTime": true,
"showLabels": false,
"wrapLogMessage": true,
"prettifyLogMessage": true,
"enableLogDetails": true,
"sortOrder": "Descending",
"dedupStrategy": "none"
},
"targets": [
{
"datasource": { "type": "loki", "uid": "loki" },
"expr": "{service_name=\"libnovel-ui\"}",
"legendFormat": ""
}
]
}
]
}

View File

@@ -38,6 +38,8 @@ services:
image: kalekber/libnovel-runner:latest
restart: unless-stopped
stop_grace_period: 135s
labels:
- "com.centurylinklabs.watchtower.enable=true"
depends_on:
- libretranslate
# Pin prod subdomains to the prod server IP to bypass Cloudflare's 100s
@@ -100,7 +102,7 @@ services:
# ── Observability ───────────────────────────────────────────────────────
LOG_LEVEL: "${LOG_LEVEL}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN}"
GLITCHTIP_DSN: "${GLITCHTIP_DSN_RUNNER}"
healthcheck:
test: ["CMD", "/healthcheck", "file", "/tmp/runner.alive", "120"]

View File

@@ -122,6 +122,13 @@ secrets-env:
secrets-dashboard:
doppler open dashboard
# ── Developer setup ───────────────────────────────────────────────────────────
# One-time dev setup: configure git to use committed hooks in .githooks/
setup:
git config core.hooksPath .githooks
@echo "Git hooks configured (.githooks/pre-commit active)."
# ── Gitea CI ──────────────────────────────────────────────────────────────────
# Validate workflow files

View File

@@ -62,6 +62,39 @@ create() {
esac
}
# add_index COLLECTION INDEX_NAME SQL_EXPR
# Fetches current schema, adds index if absent by name, PATCHes collection.
add_index() {
COLL="$1"; INAME="$2"; ISQL="$3"
SCHEMA=$(curl -sf -H "Authorization: Bearer $TOK" "$PB/api/collections/$COLL" 2>/dev/null)
PARSED=$(echo "$SCHEMA" | python3 -c "
import sys, json
d = json.load(sys.stdin)
indexes = d.get('indexes', [])
exists = any('$INAME' in idx for idx in indexes)
print('exists=' + str(exists))
print('id=' + d.get('id', ''))
if not exists:
indexes.append('$ISQL')
print('indexes=' + json.dumps(indexes))
" 2>/dev/null)
if echo "$PARSED" | grep -q "^exists=True"; then
log "index exists (skip): $COLL.$INAME"; return
fi
COLL_ID=$(echo "$PARSED" | grep "^id=" | sed 's/^id=//')
[ -z "$COLL_ID" ] && { log "WARNING: cannot resolve id for $COLL"; return; }
NEW_INDEXES=$(echo "$PARSED" | grep "^indexes=" | sed 's/^indexes=//')
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X PATCH "$PB/api/collections/$COLL_ID" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOK" \
-d "{\"indexes\":${NEW_INDEXES}}")
case "$STATUS" in
200|201) log "added index: $COLL.$INAME" ;;
*) log "WARNING: add_index $COLL.$INAME returned $STATUS" ;;
esac
}
# add_field COLLECTION FIELD_NAME FIELD_TYPE
# Fetches current schema, appends field if absent, PATCHes collection.
# Requires python3 for safe JSON manipulation.
@@ -116,9 +149,10 @@ create "books" '{
create "chapters_idx" '{
"name":"chapters_idx","type":"base","fields":[
{"name":"slug", "type":"text", "required":true},
{"name":"number","type":"number", "required":true},
{"name":"title", "type":"text"}
{"name":"slug", "type":"text", "required":true},
{"name":"number", "type":"number", "required":true},
{"name":"title", "type":"text"},
{"name":"created", "type":"date"}
]}'
create "ranking" '{
@@ -293,5 +327,12 @@ add_field "app_users" "polar_customer_id" "text"
add_field "app_users" "polar_subscription_id" "text"
add_field "user_library" "shelf" "text"
add_field "user_sessions" "device_fingerprint" "text"
add_field "chapters_idx" "created" "date"
# ── 6. Indexes ────────────────────────────────────────────────────────────────
add_index "chapters_idx" "idx_chapters_idx_slug_number" \
"CREATE UNIQUE INDEX idx_chapters_idx_slug_number ON chapters_idx (slug, number)"
add_index "chapters_idx" "idx_chapters_idx_created" \
"CREATE INDEX idx_chapters_idx_created ON chapters_idx (created)"
log "done"

View File

@@ -302,6 +302,24 @@
"book_detail_range_queuing": "Queuing\u2026",
"book_detail_scrape_range": "Scrape range",
"book_detail_admin": "Admin",
"book_detail_admin_book_cover": "Book Cover",
"book_detail_admin_chapter_cover": "Chapter Cover",
"book_detail_admin_chapter_n": "Chapter #",
"book_detail_admin_description": "Description",
"book_detail_admin_chapter_names": "Chapter Names",
"book_detail_admin_audio_tts": "Audio TTS",
"book_detail_admin_voice": "Voice",
"book_detail_admin_generate": "Generate",
"book_detail_admin_save_cover": "Save Cover",
"book_detail_admin_saving": "Saving…",
"book_detail_admin_saved": "Saved",
"book_detail_admin_apply": "Apply",
"book_detail_admin_applying": "Applying…",
"book_detail_admin_applied": "Applied",
"book_detail_admin_discard": "Discard",
"book_detail_admin_enqueue_audio": "Enqueue Audio",
"book_detail_admin_cancel_audio": "Cancel",
"book_detail_admin_enqueued": "Enqueued {enqueued}, skipped {skipped}",
"book_detail_scraping_progress": "Fetching the first 20 chapters. This page will refresh automatically.",
"book_detail_scraping_home": "\u2190 Home",
"book_detail_rescrape_book": "Rescrape book",
@@ -348,6 +366,26 @@
"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.",
"subscribe_page_title": "Go Pro \u2014 libnovel",
"subscribe_heading": "Read more. Listen more.",
"subscribe_subheading": "Upgrade to Pro and unlock the full libnovel experience.",
"subscribe_monthly_label": "Monthly",
"subscribe_monthly_price": "$6",
"subscribe_monthly_period": "per month",
"subscribe_annual_label": "Annual",
"subscribe_annual_price": "$48",
"subscribe_annual_period": "per year",
"subscribe_annual_save": "Save 33%",
"subscribe_cta_monthly": "Start monthly plan",
"subscribe_cta_annual": "Start annual plan",
"subscribe_already_pro": "You already have a Pro subscription.",
"subscribe_manage": "Manage subscription",
"subscribe_benefit_audio": "Unlimited audio chapters per day",
"subscribe_benefit_voices": "Voice selection across all TTS engines",
"subscribe_benefit_translation": "Read in French, Indonesian, Portuguese, and Russian",
"subscribe_benefit_downloads": "Download chapters for offline listening",
"subscribe_login_prompt": "Sign in to subscribe",
"subscribe_login_cta": "Sign in",
"user_currently_reading": "Currently Reading",
"user_library_count": "Library ({n})",
@@ -362,6 +400,9 @@
"admin_nav_audio": "Audio",
"admin_nav_translation": "Translation",
"admin_nav_changelog": "Changelog",
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_feedback": "Feedback",
"admin_nav_errors": "Errors",
"admin_nav_analytics": "Analytics",
@@ -369,6 +410,7 @@
"admin_nav_uptime": "Uptime",
"admin_nav_push": "Push",
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana",
"admin_scrape_status_idle": "Idle",
"admin_scrape_status_running": "Running",

View File

@@ -302,6 +302,24 @@
"book_detail_range_queuing": "En file d'attente…",
"book_detail_scrape_range": "Plage d'extraction",
"book_detail_admin": "Admin",
"book_detail_admin_book_cover": "Couverture du livre",
"book_detail_admin_chapter_cover": "Couverture du chapitre",
"book_detail_admin_chapter_n": "Chapitre n°",
"book_detail_admin_description": "Description",
"book_detail_admin_chapter_names": "Noms des chapitres",
"book_detail_admin_audio_tts": "Audio TTS",
"book_detail_admin_voice": "Voix",
"book_detail_admin_generate": "Générer",
"book_detail_admin_save_cover": "Enregistrer la couverture",
"book_detail_admin_saving": "Enregistrement…",
"book_detail_admin_saved": "Enregistré",
"book_detail_admin_apply": "Appliquer",
"book_detail_admin_applying": "Application…",
"book_detail_admin_applied": "Appliqué",
"book_detail_admin_discard": "Ignorer",
"book_detail_admin_enqueue_audio": "Mettre en file audio",
"book_detail_admin_cancel_audio": "Annuler",
"book_detail_admin_enqueued": "{enqueued} en file, {skipped} ignorés",
"book_detail_scraping_progress": "Récupération des 20 premiers chapitres. Cette page sera actualisée automatiquement.",
"book_detail_scraping_home": "← Accueil",
"book_detail_rescrape_book": "Réextraire le livre",
@@ -348,6 +366,26 @@
"profile_upgrade_monthly": "Mensuel — 6 $ / mois",
"profile_upgrade_annual": "Annuel — 48 $ / an",
"profile_free_limits": "Plan gratuit : 3 chapitres audio par jour, lecture en anglais uniquement.",
"subscribe_page_title": "Passer Pro \u2014 libnovel",
"subscribe_heading": "Lisez plus. Écoutez plus.",
"subscribe_subheading": "Passez Pro et débloquez l'expérience libnovel complète.",
"subscribe_monthly_label": "Mensuel",
"subscribe_monthly_price": "6 $",
"subscribe_monthly_period": "par mois",
"subscribe_annual_label": "Annuel",
"subscribe_annual_price": "48 $",
"subscribe_annual_period": "par an",
"subscribe_annual_save": "Économisez 33 %",
"subscribe_cta_monthly": "Commencer le plan mensuel",
"subscribe_cta_annual": "Commencer le plan annuel",
"subscribe_already_pro": "Vous avez déjà un abonnement Pro.",
"subscribe_manage": "Gérer l'abonnement",
"subscribe_benefit_audio": "Chapitres audio illimités par jour",
"subscribe_benefit_voices": "Sélection de voix pour tous les moteurs TTS",
"subscribe_benefit_translation": "Lire en français, indonésien, portugais et russe",
"subscribe_benefit_downloads": "Télécharger des chapitres pour une écoute hors ligne",
"subscribe_login_prompt": "Connectez-vous pour vous abonner",
"subscribe_login_cta": "Se connecter",
"user_currently_reading": "En cours de lecture",
"user_library_count": "Bibliothèque ({n})",
@@ -362,7 +400,9 @@
"admin_nav_audio": "Audio",
"admin_nav_translation": "Traduction",
"admin_nav_changelog": "Modifications",
"admin_nav_feedback": "Retours",
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_errors": "Erreurs",
"admin_nav_analytics": "Analytique",
"admin_nav_logs": "Journaux",
@@ -431,5 +471,6 @@
"feed_chapters_label": "{n} chapitres",
"feed_browse_cta": "Parcourir le catalogue",
"feed_find_users_cta": "Trouver des lecteurs",
"admin_nav_gitea": "Gitea"
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana"
}

View File

@@ -302,6 +302,24 @@
"book_detail_range_queuing": "Mengantri…",
"book_detail_scrape_range": "Rentang scrape",
"book_detail_admin": "Admin",
"book_detail_admin_book_cover": "Sampul Buku",
"book_detail_admin_chapter_cover": "Sampul Bab",
"book_detail_admin_chapter_n": "Bab #",
"book_detail_admin_description": "Deskripsi",
"book_detail_admin_chapter_names": "Nama Bab",
"book_detail_admin_audio_tts": "Audio TTS",
"book_detail_admin_voice": "Suara",
"book_detail_admin_generate": "Buat",
"book_detail_admin_save_cover": "Simpan Sampul",
"book_detail_admin_saving": "Menyimpan…",
"book_detail_admin_saved": "Tersimpan",
"book_detail_admin_apply": "Terapkan",
"book_detail_admin_applying": "Menerapkan…",
"book_detail_admin_applied": "Diterapkan",
"book_detail_admin_discard": "Buang",
"book_detail_admin_enqueue_audio": "Antre Audio",
"book_detail_admin_cancel_audio": "Batal",
"book_detail_admin_enqueued": "Diantre {enqueued}, dilewati {skipped}",
"book_detail_scraping_progress": "Mengambil 20 bab pertama. Halaman ini akan dimuat ulang otomatis.",
"book_detail_scraping_home": "← Beranda",
"book_detail_rescrape_book": "Scrape ulang buku",
@@ -348,6 +366,26 @@
"profile_upgrade_monthly": "Bulanan — $6 / bln",
"profile_upgrade_annual": "Tahunan — $48 / thn",
"profile_free_limits": "Paket gratis: 3 bab audio per hari, hanya bahasa Inggris.",
"subscribe_page_title": "Jadi Pro \u2014 libnovel",
"subscribe_heading": "Baca lebih. Dengarkan lebih.",
"subscribe_subheading": "Tingkatkan ke Pro dan buka pengalaman libnovel sepenuhnya.",
"subscribe_monthly_label": "Bulanan",
"subscribe_monthly_price": "$6",
"subscribe_monthly_period": "per bulan",
"subscribe_annual_label": "Tahunan",
"subscribe_annual_price": "$48",
"subscribe_annual_period": "per tahun",
"subscribe_annual_save": "Hemat 33%",
"subscribe_cta_monthly": "Mulai paket bulanan",
"subscribe_cta_annual": "Mulai paket tahunan",
"subscribe_already_pro": "Anda sudah berlangganan Pro.",
"subscribe_manage": "Kelola langganan",
"subscribe_benefit_audio": "Bab audio tak terbatas per hari",
"subscribe_benefit_voices": "Pilihan suara untuk semua mesin TTS",
"subscribe_benefit_translation": "Baca dalam bahasa Prancis, Indonesia, Portugis, dan Rusia",
"subscribe_benefit_downloads": "Unduh bab untuk didengarkan secara offline",
"subscribe_login_prompt": "Masuk untuk berlangganan",
"subscribe_login_cta": "Masuk",
"user_currently_reading": "Sedang Dibaca",
"user_library_count": "Perpustakaan ({n})",
@@ -362,7 +400,9 @@
"admin_nav_audio": "Audio",
"admin_nav_translation": "Terjemahan",
"admin_nav_changelog": "Perubahan",
"admin_nav_feedback": "Masukan",
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_errors": "Kesalahan",
"admin_nav_analytics": "Analitik",
"admin_nav_logs": "Log",
@@ -431,5 +471,6 @@
"feed_chapters_label": "{n} bab",
"feed_browse_cta": "Jelajahi katalog",
"feed_find_users_cta": "Temukan pembaca",
"admin_nav_gitea": "Gitea"
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana"
}

View File

@@ -302,6 +302,24 @@
"book_detail_range_queuing": "Na fila…",
"book_detail_scrape_range": "Intervalo de extração",
"book_detail_admin": "Admin",
"book_detail_admin_book_cover": "Capa do Livro",
"book_detail_admin_chapter_cover": "Capa do Capítulo",
"book_detail_admin_chapter_n": "Capítulo nº",
"book_detail_admin_description": "Descrição",
"book_detail_admin_chapter_names": "Nomes dos Capítulos",
"book_detail_admin_audio_tts": "Áudio TTS",
"book_detail_admin_voice": "Voz",
"book_detail_admin_generate": "Gerar",
"book_detail_admin_save_cover": "Salvar Capa",
"book_detail_admin_saving": "Salvando…",
"book_detail_admin_saved": "Salvo",
"book_detail_admin_apply": "Aplicar",
"book_detail_admin_applying": "Aplicando…",
"book_detail_admin_applied": "Aplicado",
"book_detail_admin_discard": "Descartar",
"book_detail_admin_enqueue_audio": "Enfileirar Áudio",
"book_detail_admin_cancel_audio": "Cancelar",
"book_detail_admin_enqueued": "{enqueued} enfileirados, {skipped} ignorados",
"book_detail_scraping_progress": "Buscando os primeiros 20 capítulos. Esta página será atualizada automaticamente.",
"book_detail_scraping_home": "← Início",
"book_detail_rescrape_book": "Reextrair livro",
@@ -348,6 +366,26 @@
"profile_upgrade_monthly": "Mensal — $6 / mês",
"profile_upgrade_annual": "Anual — $48 / ano",
"profile_free_limits": "Plano gratuito: 3 capítulos de áudio por dia, somente inglês.",
"subscribe_page_title": "Seja Pro \u2014 libnovel",
"subscribe_heading": "Leia mais. Ouça mais.",
"subscribe_subheading": "Torne-se Pro e desbloqueie a experiência completa do libnovel.",
"subscribe_monthly_label": "Mensal",
"subscribe_monthly_price": "$6",
"subscribe_monthly_period": "por mês",
"subscribe_annual_label": "Anual",
"subscribe_annual_price": "$48",
"subscribe_annual_period": "por ano",
"subscribe_annual_save": "Economize 33%",
"subscribe_cta_monthly": "Começar plano mensal",
"subscribe_cta_annual": "Começar plano anual",
"subscribe_already_pro": "Você já tem uma assinatura Pro.",
"subscribe_manage": "Gerenciar assinatura",
"subscribe_benefit_audio": "Capítulos de áudio ilimitados por dia",
"subscribe_benefit_voices": "Seleção de voz para todos os mecanismos TTS",
"subscribe_benefit_translation": "Leia em francês, indonésio, português e russo",
"subscribe_benefit_downloads": "Baixe capítulos para ouvir offline",
"subscribe_login_prompt": "Entre para assinar",
"subscribe_login_cta": "Entrar",
"user_currently_reading": "Lendo Agora",
"user_library_count": "Biblioteca ({n})",
@@ -362,7 +400,9 @@
"admin_nav_audio": "Áudio",
"admin_nav_translation": "Tradução",
"admin_nav_changelog": "Alterações",
"admin_nav_feedback": "Feedback",
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_errors": "Erros",
"admin_nav_analytics": "Análise",
"admin_nav_logs": "Logs",
@@ -431,5 +471,6 @@
"feed_chapters_label": "{n} capítulos",
"feed_browse_cta": "Ver catálogo",
"feed_find_users_cta": "Encontrar leitores",
"admin_nav_gitea": "Gitea"
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana"
}

View File

@@ -302,6 +302,24 @@
"book_detail_range_queuing": "В очереди…",
"book_detail_scrape_range": "Диапазон глав",
"book_detail_admin": "Администрирование",
"book_detail_admin_book_cover": "Обложка книги",
"book_detail_admin_chapter_cover": "Обложка главы",
"book_detail_admin_chapter_n": "Глава №",
"book_detail_admin_description": "Описание",
"book_detail_admin_chapter_names": "Названия глав",
"book_detail_admin_audio_tts": "Аудио TTS",
"book_detail_admin_voice": "Голос",
"book_detail_admin_generate": "Сгенерировать",
"book_detail_admin_save_cover": "Сохранить обложку",
"book_detail_admin_saving": "Сохранение…",
"book_detail_admin_saved": "Сохранено",
"book_detail_admin_apply": "Применить",
"book_detail_admin_applying": "Применение…",
"book_detail_admin_applied": "Применено",
"book_detail_admin_discard": "Отменить",
"book_detail_admin_enqueue_audio": "Поставить в очередь",
"book_detail_admin_cancel_audio": "Отмена",
"book_detail_admin_enqueued": "В очереди {enqueued}, пропущено {skipped}",
"book_detail_scraping_progress": "Загружаются первые 20 глав. Страница обновится автоматически.",
"book_detail_scraping_home": "← На главную",
"book_detail_rescrape_book": "Перепарсить книгу",
@@ -348,6 +366,26 @@
"profile_upgrade_monthly": "Ежемесячно — $6 / мес",
"profile_upgrade_annual": "Ежегодно — $48 / год",
"profile_free_limits": "Бесплатный план: 3 аудиоглавы в день, только английский.",
"subscribe_page_title": "Перейти на Pro \u2014 libnovel",
"subscribe_heading": "Читайте больше. Слушайте больше.",
"subscribe_subheading": "Перейдите на Pro и откройте полный опыт libnovel.",
"subscribe_monthly_label": "Ежемесячно",
"subscribe_monthly_price": "$6",
"subscribe_monthly_period": "в месяц",
"subscribe_annual_label": "Ежегодно",
"subscribe_annual_price": "$48",
"subscribe_annual_period": "в год",
"subscribe_annual_save": "Сэкономьте 33%",
"subscribe_cta_monthly": "Начать месячный план",
"subscribe_cta_annual": "Начать годовой план",
"subscribe_already_pro": "У вас уже есть подписка Pro.",
"subscribe_manage": "Управление подпиской",
"subscribe_benefit_audio": "Неограниченные аудиоглавы в день",
"subscribe_benefit_voices": "Выбор голоса для всех TTS-движков",
"subscribe_benefit_translation": "Читайте на французском, индонезийском, португальском и русском",
"subscribe_benefit_downloads": "Скачивайте главы для прослушивания офлайн",
"subscribe_login_prompt": "Войдите, чтобы оформить подписку",
"subscribe_login_cta": "Войти",
"user_currently_reading": "Сейчас читает",
"user_library_count": "Библиотека ({n})",
@@ -362,7 +400,9 @@
"admin_nav_audio": "Аудио",
"admin_nav_translation": "Перевод",
"admin_nav_changelog": "Изменения",
"admin_nav_feedback": "Отзывы",
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_catalogue_tools": "Catalogue Tools",
"admin_nav_errors": "Ошибки",
"admin_nav_analytics": "Аналитика",
"admin_nav_logs": "Логи",
@@ -431,5 +471,6 @@
"feed_chapters_label": "{n} глав",
"feed_browse_cta": "Каталог",
"feed_find_users_cta": "Найти читателей",
"admin_nav_gitea": "Gitea"
"admin_nav_gitea": "Gitea",
"admin_nav_grafana": "Grafana"
}

142
ui/package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0",
"@grafana/faro-web-sdk": "^2.3.1",
"@inlang/paraglide-js": "^2.15.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
@@ -1689,6 +1690,115 @@
"module-details-from-path": "^1.0.4"
}
},
"node_modules/@grafana/faro-core": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@grafana/faro-core/-/faro-core-2.3.1.tgz",
"integrity": "sha512-htDKO0YFKr0tfntrPoM151vOPSZzmP6oE0+0MDvbI1WDaBW4erXmYi3feGJLWDXt5/vZBg9iQRmZoRzTLTTcOA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/otlp-transformer": "^0.213.0"
}
},
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/otlp-transformer": {
"version": "0.213.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.213.0.tgz",
"integrity": "sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.213.0",
"@opentelemetry/core": "2.6.0",
"@opentelemetry/resources": "2.6.0",
"@opentelemetry/sdk-logs": "0.213.0",
"@opentelemetry/sdk-metrics": "2.6.0",
"@opentelemetry/sdk-trace-base": "2.6.0",
"protobufjs": "^7.0.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/resources": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz",
"integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.6.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/sdk-logs": {
"version": "0.213.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.213.0.tgz",
"integrity": "sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.213.0",
"@opentelemetry/core": "2.6.0",
"@opentelemetry/resources": "2.6.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.4.0 <1.10.0"
}
},
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/sdk-metrics": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.0.tgz",
"integrity": "sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.6.0",
"@opentelemetry/resources": "2.6.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.9.0 <1.10.0"
}
},
"node_modules/@grafana/faro-core/node_modules/@opentelemetry/sdk-trace-base": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz",
"integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.6.0",
"@opentelemetry/resources": "2.6.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@grafana/faro-web-sdk": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@grafana/faro-web-sdk/-/faro-web-sdk-2.3.1.tgz",
"integrity": "sha512-WMfErl2YSP+CcfcobMpCdK6apX86hc8bymMXsvYLQpBBkQ0KJjIilEQS/YXd+g/cg6F1kwbeweisBKluNNy5sA==",
"license": "Apache-2.0",
"dependencies": {
"@grafana/faro-core": "^2.3.1",
"ua-parser-js": "1.0.41",
"web-vitals": "^5.1.0"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
@@ -7377,6 +7487,32 @@
"node": ">=14.17"
}
},
"node_modules/ua-parser-js": {
"version": "1.0.41",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/ua-parser-js"
},
{
"type": "paypal",
"url": "https://paypal.me/faisalman"
},
{
"type": "github",
"url": "https://github.com/sponsors/faisalman"
}
],
"license": "MIT",
"bin": {
"ua-parser-js": "script/cli.js"
},
"engines": {
"node": "*"
}
},
"node_modules/undici-types": {
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
@@ -7540,6 +7676,12 @@
}
}
},
"node_modules/web-vitals": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz",
"integrity": "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",

View File

@@ -31,6 +31,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0",
"@grafana/faro-web-sdk": "^2.3.1",
"@inlang/paraglide-js": "^2.15.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",

View File

@@ -106,12 +106,14 @@ html {
:root {
--reading-font: system-ui, -apple-system, sans-serif;
--reading-size: 1.05rem;
--reading-line-height: 1.85;
--reading-max-width: 72ch;
}
/* ── Chapter prose ─────────────────────────────────────────────────── */
.prose-chapter {
max-width: 72ch;
line-height: 1.85;
max-width: var(--reading-max-width, 72ch);
line-height: var(--reading-line-height, 1.85);
font-family: var(--reading-font);
font-size: var(--reading-size);
color: var(--color-muted);
@@ -134,6 +136,12 @@ html {
margin-bottom: 1.2em;
}
/* Indented paragraph style — book-like, no gap, indent instead */
.prose-chapter.para-indented p {
text-indent: 2em;
margin-bottom: 0.35em;
}
.prose-chapter em {
color: var(--color-muted);
}
@@ -147,6 +155,31 @@ html {
margin: 2em 0;
}
/* ── Reading progress bar ───────────────────────────────────────────── */
.reading-progress {
position: fixed;
top: 0;
left: 0;
height: 2px;
z-index: 100;
background: var(--color-brand);
pointer-events: none;
transition: width 0.1s linear;
}
/* ── Paginated reader ───────────────────────────────────────────────── */
.paginated-container {
overflow: hidden;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
.paginated-container .prose-chapter {
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform;
}
/* ── Hide scrollbars (used on horizontal carousels) ────────────────── */
.scrollbar-none {
scrollbar-width: none; /* Firefox */

View File

@@ -1,5 +1,6 @@
import * as Sentry from '@sentry/sveltekit';
import { env } from '$env/dynamic/public';
import { initializeFaro, getWebInstrumentations } from '@grafana/faro-web-sdk';
// Sentry / GlitchTip client-side error tracking.
// No-op when PUBLIC_GLITCHTIP_DSN is unset (e.g. local dev).
@@ -13,4 +14,21 @@ if (env.PUBLIC_GLITCHTIP_DSN) {
});
}
// Grafana Faro RUM — browser performance monitoring (Web Vitals, traces, errors).
// No-op when PUBLIC_FARO_COLLECTOR_URL is unset (e.g. local dev).
if (env.PUBLIC_FARO_COLLECTOR_URL) {
initializeFaro({
url: env.PUBLIC_FARO_COLLECTOR_URL,
app: {
name: 'libnovel-ui',
version: env.PUBLIC_BUILD_VERSION || 'dev',
environment: 'production'
},
instrumentations: [
// Core Web Vitals (LCP, CLS, INP, TTFB, FCP) + JS errors + console
...getWebInstrumentations({ captureConsole: false })
]
});
}
export const handleError = Sentry.handleErrorWithSentry();

View File

@@ -69,6 +69,8 @@
voices?: Voice[];
/** Called when the server returns 402 (free daily limit reached). */
onProRequired?: () => void;
/** Visual style of the player card. 'standard' = full controls; 'compact' = slim seekable player. */
playerStyle?: 'standard' | 'compact';
}
let {
@@ -80,7 +82,8 @@
nextChapter = null,
chapters = [],
voices = [],
onProRequired = undefined
onProRequired = undefined,
playerStyle = 'standard'
}: Props = $props();
// ── Derived: voices grouped by engine ──────────────────────────────────
@@ -564,7 +567,35 @@
return;
}
// Slow path: trigger Kokoro generation (non-blocking POST), then poll.
// Slow path: audio not yet in MinIO.
//
// For Kokoro / PocketTTS when presign has NOT already enqueued the runner:
// use the streaming endpoint — audio starts playing within seconds while
// generation runs and MinIO is populated concurrently.
// Skip when enqueued=true to avoid double-generation with the async runner.
if (!voice.startsWith('cfai:') && !presignResult.enqueued) {
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
// WAV (PCM) is natively supported on all platforms including iOS Safari.
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts');
const format = isPocketTTS ? 'wav' : 'mp3';
const qs = new URLSearchParams({ voice, format });
const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`;
// HEAD probe: check paywall without triggering generation.
const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null);
if (headRes?.status === 402) {
audioStore.status = 'idle';
onProRequired?.();
return;
}
audioStore.audioUrl = streamUrl;
audioStore.status = 'ready';
maybeStartPrefetch();
return;
}
// CF AI (batch-only) or already enqueued by presign: keep the traditional
// POST → poll → presign flow. For enqueued, we skip the POST and poll.
audioStore.status = 'generating';
startProgress();
@@ -738,6 +769,24 @@
if (m > 0) return `${m}m`;
return `${s}s`;
}
// ── Compact player helpers ─────────────────────────────────────────────────
const playPct = $derived(
audioStore.duration > 0 ? (audioStore.currentTime / audioStore.duration) * 100 : 0
);
const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const;
function cycleSpeed() {
const idx = SPEED_OPTIONS.indexOf(audioStore.speed as (typeof SPEED_OPTIONS)[number]);
audioStore.speed = SPEED_OPTIONS[(idx + 1) % SPEED_OPTIONS.length];
}
function seekFromCompactBar(e: MouseEvent) {
if (audioStore.duration <= 0) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
audioStore.seekRequest = pct * audioStore.duration;
}
</script>
<svelte:window onkeydown={handleKeyDown} />
@@ -788,6 +837,122 @@
</div>
{/snippet}
{#if playerStyle === 'compact'}
<!-- ── Compact player ──────────────────────────────────────────────────────── -->
<div class="mt-4 p-3 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
{#if audioStore.isCurrentChapter(slug, chapter)}
{#if audioStore.status === 'idle' || audioStore.status === 'error'}
{#if audioStore.status === 'error'}
<p class="text-(--color-danger) text-xs mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
{/if}
<Button variant="default" size="sm" onclick={handlePlay}>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{m.reader_play_narration()}
</Button>
{:else if audioStore.status === 'loading'}
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{m.player_loading()}
</div>
{:else if audioStore.status === 'generating'}
<div class="space-y-1.5">
<div class="flex items-center justify-between text-xs text-(--color-muted)">
<span>{m.reader_generating_narration()}</span>
<span class="tabular-nums">{Math.round(audioStore.progress)}%</span>
</div>
<div class="w-full h-1 bg-(--color-surface-3) rounded-full overflow-hidden">
<div class="h-full bg-(--color-brand) rounded-full transition-none" style="width: {audioStore.progress}%"></div>
</div>
</div>
{:else if audioStore.status === 'ready'}
<div class="space-y-2">
<!-- Seekable progress bar -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
role="none"
class="w-full h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden cursor-pointer group"
onclick={seekFromCompactBar}
>
<div class="h-full bg-(--color-brand) rounded-full transition-none" style="width: {playPct}%"></div>
</div>
<!-- Controls row -->
<div class="flex items-center gap-2">
<!-- Skip back 15s -->
<button
type="button"
onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
class="text-(--color-muted) hover:text-(--color-text) transition-colors flex-shrink-0"
title="-15s"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
</svg>
</button>
<!-- Play/pause -->
<button
type="button"
onclick={() => { audioStore.toggleRequest++; }}
class="w-8 h-8 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors flex-shrink-0"
>
{#if audioStore.isPlaying}
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
{:else}
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{/if}
</button>
<!-- Skip forward 30s -->
<button
type="button"
onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
class="text-(--color-muted) hover:text-(--color-text) transition-colors flex-shrink-0"
title="+30s"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
</svg>
</button>
<!-- Time display -->
<span class="flex-1 text-xs text-center tabular-nums text-(--color-muted)">
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
</span>
<!-- Speed cycle -->
<button
type="button"
onclick={cycleSpeed}
class="text-xs font-medium text-(--color-muted) hover:text-(--color-text) flex-shrink-0 tabular-nums transition-colors"
title="Playback speed"
>
{audioStore.speed}×
</button>
</div>
</div>
{/if}
{:else if audioStore.active}
<div class="flex items-center justify-between gap-3">
<p class="text-xs text-(--color-muted)">
{m.reader_now_playing({ title: audioStore.chapterTitle || `Ch.${audioStore.chapter}` })}
</p>
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
{m.reader_load_this_chapter()}
</Button>
</div>
{:else}
<Button variant="default" size="sm" onclick={handlePlay}>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
{m.reader_play_narration()}
</Button>
{/if}
</div>
{:else}
<!-- ── Standard player ─────────────────────────────────────────────────────── -->
<div class="mt-6 p-4 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-2">
@@ -1026,3 +1191,4 @@
</Button>
{/if}
</div>
{/if}

View File

@@ -18,6 +18,13 @@
}
}
function handleBackdropKeyDown(e: KeyboardEvent) {
if ((e.key === 'Enter' || e.key === ' ') && e.target === e.currentTarget) {
open = false;
onclose?.();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
open = false;
@@ -34,7 +41,9 @@
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
tabindex="-1"
onclick={handleBackdropClick}
onkeydown={handleBackdropKeyDown}
>
<div class={cn('bg-(--color-surface) rounded-2xl border border-(--color-border) shadow-2xl w-full max-w-sm', className)}>
{@render children?.()}

View File

@@ -279,6 +279,24 @@ export * from './book_detail_to_chapter.js'
export * from './book_detail_range_queuing.js'
export * from './book_detail_scrape_range.js'
export * from './book_detail_admin.js'
export * from './book_detail_admin_book_cover.js'
export * from './book_detail_admin_chapter_cover.js'
export * from './book_detail_admin_chapter_n.js'
export * from './book_detail_admin_description.js'
export * from './book_detail_admin_chapter_names.js'
export * from './book_detail_admin_audio_tts.js'
export * from './book_detail_admin_voice.js'
export * from './book_detail_admin_generate.js'
export * from './book_detail_admin_save_cover.js'
export * from './book_detail_admin_saving.js'
export * from './book_detail_admin_saved.js'
export * from './book_detail_admin_apply.js'
export * from './book_detail_admin_applying.js'
export * from './book_detail_admin_applied.js'
export * from './book_detail_admin_discard.js'
export * from './book_detail_admin_enqueue_audio.js'
export * from './book_detail_admin_cancel_audio.js'
export * from './book_detail_admin_enqueued.js'
export * from './book_detail_scraping_progress.js'
export * from './book_detail_scraping_home.js'
export * from './book_detail_rescrape_book.js'
@@ -321,6 +339,26 @@ export * from './profile_upgrade_desc.js'
export * from './profile_upgrade_monthly.js'
export * from './profile_upgrade_annual.js'
export * from './profile_free_limits.js'
export * from './subscribe_page_title.js'
export * from './subscribe_heading.js'
export * from './subscribe_subheading.js'
export * from './subscribe_monthly_label.js'
export * from './subscribe_monthly_price.js'
export * from './subscribe_monthly_period.js'
export * from './subscribe_annual_label.js'
export * from './subscribe_annual_price.js'
export * from './subscribe_annual_period.js'
export * from './subscribe_annual_save.js'
export * from './subscribe_cta_monthly.js'
export * from './subscribe_cta_annual.js'
export * from './subscribe_already_pro.js'
export * from './subscribe_manage.js'
export * from './subscribe_benefit_audio.js'
export * from './subscribe_benefit_voices.js'
export * from './subscribe_benefit_translation.js'
export * from './subscribe_benefit_downloads.js'
export * from './subscribe_login_prompt.js'
export * from './subscribe_login_cta.js'
export * from './user_currently_reading.js'
export * from './user_library_count.js'
export * from './user_joined.js'
@@ -333,6 +371,9 @@ export * from './admin_nav_scrape.js'
export * from './admin_nav_audio.js'
export * from './admin_nav_translation.js'
export * from './admin_nav_changelog.js'
export * from './admin_nav_image_gen.js'
export * from './admin_nav_text_gen.js'
export * from './admin_nav_catalogue_tools.js'
export * from './admin_nav_feedback.js'
export * from './admin_nav_errors.js'
export * from './admin_nav_analytics.js'
@@ -340,6 +381,7 @@ export * from './admin_nav_logs.js'
export * from './admin_nav_uptime.js'
export * from './admin_nav_push.js'
export * from './admin_nav_gitea.js'
export * from './admin_nav_grafana.js'
export * from './admin_scrape_status_idle.js'
export * from './admin_scrape_full_catalogue.js'
export * from './admin_scrape_single_book.js'

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_Catalogue_ToolsInputs */
const en_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Catalogue Tools`)
};
const ru_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Catalogue Tools`)
};
const id_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Catalogue Tools`)
};
const pt_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Catalogue Tools`)
};
const fr_admin_nav_catalogue_tools = /** @type {(inputs: Admin_Nav_Catalogue_ToolsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Catalogue Tools`)
};
/**
* | output |
* | --- |
* | "Catalogue Tools" |
*
* @param {Admin_Nav_Catalogue_ToolsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_catalogue_tools = /** @type {((inputs?: Admin_Nav_Catalogue_ToolsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_Catalogue_ToolsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_catalogue_tools(inputs)
if (locale === "ru") return ru_admin_nav_catalogue_tools(inputs)
if (locale === "id") return id_admin_nav_catalogue_tools(inputs)
if (locale === "pt") return pt_admin_nav_catalogue_tools(inputs)
return fr_admin_nav_catalogue_tools(inputs)
});

View File

@@ -9,21 +9,17 @@ const en_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => L
return /** @type {LocalizedString} */ (`Feedback`)
};
const ru_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Отзывы`)
};
/** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */
const ru_admin_nav_feedback = en_admin_nav_feedback;
const id_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Masukan`)
};
/** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */
const id_admin_nav_feedback = en_admin_nav_feedback;
const pt_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Feedback`)
};
/** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */
const pt_admin_nav_feedback = en_admin_nav_feedback;
const fr_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Retours`)
};
/** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */
const fr_admin_nav_feedback = en_admin_nav_feedback;
/**
* | output |

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_GrafanaInputs */
const en_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Grafana`)
};
const ru_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Grafana`)
};
const id_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Grafana`)
};
const pt_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Grafana`)
};
const fr_admin_nav_grafana = /** @type {(inputs: Admin_Nav_GrafanaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Grafana`)
};
/**
* | output |
* | --- |
* | "Grafana" |
*
* @param {Admin_Nav_GrafanaInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_grafana = /** @type {((inputs?: Admin_Nav_GrafanaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_GrafanaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_grafana(inputs)
if (locale === "ru") return ru_admin_nav_grafana(inputs)
if (locale === "id") return id_admin_nav_grafana(inputs)
if (locale === "pt") return pt_admin_nav_grafana(inputs)
return fr_admin_nav_grafana(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_Image_GenInputs */
const en_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Image Gen`)
};
const ru_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Image Gen`)
};
const id_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Image Gen`)
};
const pt_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Image Gen`)
};
const fr_admin_nav_image_gen = /** @type {(inputs: Admin_Nav_Image_GenInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Image Gen`)
};
/**
* | output |
* | --- |
* | "Image Gen" |
*
* @param {Admin_Nav_Image_GenInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_image_gen = /** @type {((inputs?: Admin_Nav_Image_GenInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_Image_GenInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_image_gen(inputs)
if (locale === "ru") return ru_admin_nav_image_gen(inputs)
if (locale === "id") return id_admin_nav_image_gen(inputs)
if (locale === "pt") return pt_admin_nav_image_gen(inputs)
return fr_admin_nav_image_gen(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_Text_GenInputs */
const en_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Text Gen`)
};
const ru_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Text Gen`)
};
const id_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Text Gen`)
};
const pt_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Text Gen`)
};
const fr_admin_nav_text_gen = /** @type {(inputs: Admin_Nav_Text_GenInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Text Gen`)
};
/**
* | output |
* | --- |
* | "Text Gen" |
*
* @param {Admin_Nav_Text_GenInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_text_gen = /** @type {((inputs?: Admin_Nav_Text_GenInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_Text_GenInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_text_gen(inputs)
if (locale === "ru") return ru_admin_nav_text_gen(inputs)
if (locale === "id") return id_admin_nav_text_gen(inputs)
if (locale === "pt") return pt_admin_nav_text_gen(inputs)
return fr_admin_nav_text_gen(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_AppliedInputs */
const en_book_detail_admin_applied = /** @type {(inputs: Book_Detail_Admin_AppliedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Applied`)
};
const ru_book_detail_admin_applied = /** @type {(inputs: Book_Detail_Admin_AppliedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Применено`)
};
const id_book_detail_admin_applied = /** @type {(inputs: Book_Detail_Admin_AppliedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Diterapkan`)
};
const pt_book_detail_admin_applied = /** @type {(inputs: Book_Detail_Admin_AppliedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Aplicado`)
};
const fr_book_detail_admin_applied = /** @type {(inputs: Book_Detail_Admin_AppliedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Appliqué`)
};
/**
* | output |
* | --- |
* | "Applied" |
*
* @param {Book_Detail_Admin_AppliedInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_applied = /** @type {((inputs?: Book_Detail_Admin_AppliedInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_AppliedInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_applied(inputs)
if (locale === "ru") return ru_book_detail_admin_applied(inputs)
if (locale === "id") return id_book_detail_admin_applied(inputs)
if (locale === "pt") return pt_book_detail_admin_applied(inputs)
return fr_book_detail_admin_applied(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_ApplyInputs */
const en_book_detail_admin_apply = /** @type {(inputs: Book_Detail_Admin_ApplyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Apply`)
};
const ru_book_detail_admin_apply = /** @type {(inputs: Book_Detail_Admin_ApplyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Применить`)
};
const id_book_detail_admin_apply = /** @type {(inputs: Book_Detail_Admin_ApplyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Terapkan`)
};
const pt_book_detail_admin_apply = /** @type {(inputs: Book_Detail_Admin_ApplyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Aplicar`)
};
const fr_book_detail_admin_apply = /** @type {(inputs: Book_Detail_Admin_ApplyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Appliquer`)
};
/**
* | output |
* | --- |
* | "Apply" |
*
* @param {Book_Detail_Admin_ApplyInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_apply = /** @type {((inputs?: Book_Detail_Admin_ApplyInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_ApplyInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_apply(inputs)
if (locale === "ru") return ru_book_detail_admin_apply(inputs)
if (locale === "id") return id_book_detail_admin_apply(inputs)
if (locale === "pt") return pt_book_detail_admin_apply(inputs)
return fr_book_detail_admin_apply(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_ApplyingInputs */
const en_book_detail_admin_applying = /** @type {(inputs: Book_Detail_Admin_ApplyingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Applying…`)
};
const ru_book_detail_admin_applying = /** @type {(inputs: Book_Detail_Admin_ApplyingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Применение…`)
};
const id_book_detail_admin_applying = /** @type {(inputs: Book_Detail_Admin_ApplyingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Menerapkan…`)
};
const pt_book_detail_admin_applying = /** @type {(inputs: Book_Detail_Admin_ApplyingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Aplicando…`)
};
const fr_book_detail_admin_applying = /** @type {(inputs: Book_Detail_Admin_ApplyingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Application…`)
};
/**
* | output |
* | --- |
* | "Applying…" |
*
* @param {Book_Detail_Admin_ApplyingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_applying = /** @type {((inputs?: Book_Detail_Admin_ApplyingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_ApplyingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_applying(inputs)
if (locale === "ru") return ru_book_detail_admin_applying(inputs)
if (locale === "id") return id_book_detail_admin_applying(inputs)
if (locale === "pt") return pt_book_detail_admin_applying(inputs)
return fr_book_detail_admin_applying(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_Audio_TtsInputs */
const en_book_detail_admin_audio_tts = /** @type {(inputs: Book_Detail_Admin_Audio_TtsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio TTS`)
};
const ru_book_detail_admin_audio_tts = /** @type {(inputs: Book_Detail_Admin_Audio_TtsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Аудио TTS`)
};
const id_book_detail_admin_audio_tts = /** @type {(inputs: Book_Detail_Admin_Audio_TtsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio TTS`)
};
const pt_book_detail_admin_audio_tts = /** @type {(inputs: Book_Detail_Admin_Audio_TtsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Áudio TTS`)
};
const fr_book_detail_admin_audio_tts = /** @type {(inputs: Book_Detail_Admin_Audio_TtsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio TTS`)
};
/**
* | output |
* | --- |
* | "Audio TTS" |
*
* @param {Book_Detail_Admin_Audio_TtsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_audio_tts = /** @type {((inputs?: Book_Detail_Admin_Audio_TtsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Audio_TtsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_audio_tts(inputs)
if (locale === "ru") return ru_book_detail_admin_audio_tts(inputs)
if (locale === "id") return id_book_detail_admin_audio_tts(inputs)
if (locale === "pt") return pt_book_detail_admin_audio_tts(inputs)
return fr_book_detail_admin_audio_tts(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_Book_CoverInputs */
const en_book_detail_admin_book_cover = /** @type {(inputs: Book_Detail_Admin_Book_CoverInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Book Cover`)
};
const ru_book_detail_admin_book_cover = /** @type {(inputs: Book_Detail_Admin_Book_CoverInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Обложка книги`)
};
const id_book_detail_admin_book_cover = /** @type {(inputs: Book_Detail_Admin_Book_CoverInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Sampul Buku`)
};
const pt_book_detail_admin_book_cover = /** @type {(inputs: Book_Detail_Admin_Book_CoverInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Capa do Livro`)
};
const fr_book_detail_admin_book_cover = /** @type {(inputs: Book_Detail_Admin_Book_CoverInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Couverture du livre`)
};
/**
* | output |
* | --- |
* | "Book Cover" |
*
* @param {Book_Detail_Admin_Book_CoverInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_book_cover = /** @type {((inputs?: Book_Detail_Admin_Book_CoverInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Book_CoverInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_book_cover(inputs)
if (locale === "ru") return ru_book_detail_admin_book_cover(inputs)
if (locale === "id") return id_book_detail_admin_book_cover(inputs)
if (locale === "pt") return pt_book_detail_admin_book_cover(inputs)
return fr_book_detail_admin_book_cover(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_Cancel_AudioInputs */
const en_book_detail_admin_cancel_audio = /** @type {(inputs: Book_Detail_Admin_Cancel_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Cancel`)
};
const ru_book_detail_admin_cancel_audio = /** @type {(inputs: Book_Detail_Admin_Cancel_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Отмена`)
};
const id_book_detail_admin_cancel_audio = /** @type {(inputs: Book_Detail_Admin_Cancel_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Batal`)
};
const pt_book_detail_admin_cancel_audio = /** @type {(inputs: Book_Detail_Admin_Cancel_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Cancelar`)
};
const fr_book_detail_admin_cancel_audio = /** @type {(inputs: Book_Detail_Admin_Cancel_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Annuler`)
};
/**
* | output |
* | --- |
* | "Cancel" |
*
* @param {Book_Detail_Admin_Cancel_AudioInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_cancel_audio = /** @type {((inputs?: Book_Detail_Admin_Cancel_AudioInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Cancel_AudioInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_cancel_audio(inputs)
if (locale === "ru") return ru_book_detail_admin_cancel_audio(inputs)
if (locale === "id") return id_book_detail_admin_cancel_audio(inputs)
if (locale === "pt") return pt_book_detail_admin_cancel_audio(inputs)
return fr_book_detail_admin_cancel_audio(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_Chapter_CoverInputs */
const en_book_detail_admin_chapter_cover = /** @type {(inputs: Book_Detail_Admin_Chapter_CoverInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Chapter Cover`)
};
const ru_book_detail_admin_chapter_cover = /** @type {(inputs: Book_Detail_Admin_Chapter_CoverInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Обложка главы`)
};
const id_book_detail_admin_chapter_cover = /** @type {(inputs: Book_Detail_Admin_Chapter_CoverInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Sampul Bab`)
};
const pt_book_detail_admin_chapter_cover = /** @type {(inputs: Book_Detail_Admin_Chapter_CoverInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Capa do Capítulo`)
};
const fr_book_detail_admin_chapter_cover = /** @type {(inputs: Book_Detail_Admin_Chapter_CoverInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Couverture du chapitre`)
};
/**
* | output |
* | --- |
* | "Chapter Cover" |
*
* @param {Book_Detail_Admin_Chapter_CoverInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_chapter_cover = /** @type {((inputs?: Book_Detail_Admin_Chapter_CoverInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Chapter_CoverInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_chapter_cover(inputs)
if (locale === "ru") return ru_book_detail_admin_chapter_cover(inputs)
if (locale === "id") return id_book_detail_admin_chapter_cover(inputs)
if (locale === "pt") return pt_book_detail_admin_chapter_cover(inputs)
return fr_book_detail_admin_chapter_cover(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_Chapter_NInputs */
const en_book_detail_admin_chapter_n = /** @type {(inputs: Book_Detail_Admin_Chapter_NInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Chapter #`)
};
const ru_book_detail_admin_chapter_n = /** @type {(inputs: Book_Detail_Admin_Chapter_NInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Глава №`)
};
const id_book_detail_admin_chapter_n = /** @type {(inputs: Book_Detail_Admin_Chapter_NInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Bab #`)
};
const pt_book_detail_admin_chapter_n = /** @type {(inputs: Book_Detail_Admin_Chapter_NInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Capítulo nº`)
};
const fr_book_detail_admin_chapter_n = /** @type {(inputs: Book_Detail_Admin_Chapter_NInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Chapitre n°`)
};
/**
* | output |
* | --- |
* | "Chapter #" |
*
* @param {Book_Detail_Admin_Chapter_NInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_chapter_n = /** @type {((inputs?: Book_Detail_Admin_Chapter_NInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Chapter_NInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_chapter_n(inputs)
if (locale === "ru") return ru_book_detail_admin_chapter_n(inputs)
if (locale === "id") return id_book_detail_admin_chapter_n(inputs)
if (locale === "pt") return pt_book_detail_admin_chapter_n(inputs)
return fr_book_detail_admin_chapter_n(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_Chapter_NamesInputs */
const en_book_detail_admin_chapter_names = /** @type {(inputs: Book_Detail_Admin_Chapter_NamesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Chapter Names`)
};
const ru_book_detail_admin_chapter_names = /** @type {(inputs: Book_Detail_Admin_Chapter_NamesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Названия глав`)
};
const id_book_detail_admin_chapter_names = /** @type {(inputs: Book_Detail_Admin_Chapter_NamesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Nama Bab`)
};
const pt_book_detail_admin_chapter_names = /** @type {(inputs: Book_Detail_Admin_Chapter_NamesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Nomes dos Capítulos`)
};
const fr_book_detail_admin_chapter_names = /** @type {(inputs: Book_Detail_Admin_Chapter_NamesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Noms des chapitres`)
};
/**
* | output |
* | --- |
* | "Chapter Names" |
*
* @param {Book_Detail_Admin_Chapter_NamesInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_chapter_names = /** @type {((inputs?: Book_Detail_Admin_Chapter_NamesInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Chapter_NamesInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_chapter_names(inputs)
if (locale === "ru") return ru_book_detail_admin_chapter_names(inputs)
if (locale === "id") return id_book_detail_admin_chapter_names(inputs)
if (locale === "pt") return pt_book_detail_admin_chapter_names(inputs)
return fr_book_detail_admin_chapter_names(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_DescriptionInputs */
const en_book_detail_admin_description = /** @type {(inputs: Book_Detail_Admin_DescriptionInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Description`)
};
const ru_book_detail_admin_description = /** @type {(inputs: Book_Detail_Admin_DescriptionInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Описание`)
};
const id_book_detail_admin_description = /** @type {(inputs: Book_Detail_Admin_DescriptionInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Deskripsi`)
};
const pt_book_detail_admin_description = /** @type {(inputs: Book_Detail_Admin_DescriptionInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Descrição`)
};
const fr_book_detail_admin_description = /** @type {(inputs: Book_Detail_Admin_DescriptionInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Description`)
};
/**
* | output |
* | --- |
* | "Description" |
*
* @param {Book_Detail_Admin_DescriptionInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_description = /** @type {((inputs?: Book_Detail_Admin_DescriptionInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_DescriptionInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_description(inputs)
if (locale === "ru") return ru_book_detail_admin_description(inputs)
if (locale === "id") return id_book_detail_admin_description(inputs)
if (locale === "pt") return pt_book_detail_admin_description(inputs)
return fr_book_detail_admin_description(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_DiscardInputs */
const en_book_detail_admin_discard = /** @type {(inputs: Book_Detail_Admin_DiscardInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Discard`)
};
const ru_book_detail_admin_discard = /** @type {(inputs: Book_Detail_Admin_DiscardInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Отменить`)
};
const id_book_detail_admin_discard = /** @type {(inputs: Book_Detail_Admin_DiscardInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Buang`)
};
const pt_book_detail_admin_discard = /** @type {(inputs: Book_Detail_Admin_DiscardInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Descartar`)
};
const fr_book_detail_admin_discard = /** @type {(inputs: Book_Detail_Admin_DiscardInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Ignorer`)
};
/**
* | output |
* | --- |
* | "Discard" |
*
* @param {Book_Detail_Admin_DiscardInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_discard = /** @type {((inputs?: Book_Detail_Admin_DiscardInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_DiscardInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_discard(inputs)
if (locale === "ru") return ru_book_detail_admin_discard(inputs)
if (locale === "id") return id_book_detail_admin_discard(inputs)
if (locale === "pt") return pt_book_detail_admin_discard(inputs)
return fr_book_detail_admin_discard(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_Enqueue_AudioInputs */
const en_book_detail_admin_enqueue_audio = /** @type {(inputs: Book_Detail_Admin_Enqueue_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Enqueue Audio`)
};
const ru_book_detail_admin_enqueue_audio = /** @type {(inputs: Book_Detail_Admin_Enqueue_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Поставить в очередь`)
};
const id_book_detail_admin_enqueue_audio = /** @type {(inputs: Book_Detail_Admin_Enqueue_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Antre Audio`)
};
const pt_book_detail_admin_enqueue_audio = /** @type {(inputs: Book_Detail_Admin_Enqueue_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Enfileirar Áudio`)
};
const fr_book_detail_admin_enqueue_audio = /** @type {(inputs: Book_Detail_Admin_Enqueue_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mettre en file audio`)
};
/**
* | output |
* | --- |
* | "Enqueue Audio" |
*
* @param {Book_Detail_Admin_Enqueue_AudioInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_enqueue_audio = /** @type {((inputs?: Book_Detail_Admin_Enqueue_AudioInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Enqueue_AudioInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_enqueue_audio(inputs)
if (locale === "ru") return ru_book_detail_admin_enqueue_audio(inputs)
if (locale === "id") return id_book_detail_admin_enqueue_audio(inputs)
if (locale === "pt") return pt_book_detail_admin_enqueue_audio(inputs)
return fr_book_detail_admin_enqueue_audio(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{ enqueued: NonNullable<unknown>, skipped: NonNullable<unknown> }} Book_Detail_Admin_EnqueuedInputs */
const en_book_detail_admin_enqueued = /** @type {(inputs: Book_Detail_Admin_EnqueuedInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`Enqueued ${i?.enqueued}, skipped ${i?.skipped}`)
};
const ru_book_detail_admin_enqueued = /** @type {(inputs: Book_Detail_Admin_EnqueuedInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`В очереди ${i?.enqueued}, пропущено ${i?.skipped}`)
};
const id_book_detail_admin_enqueued = /** @type {(inputs: Book_Detail_Admin_EnqueuedInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`Diantre ${i?.enqueued}, dilewati ${i?.skipped}`)
};
const pt_book_detail_admin_enqueued = /** @type {(inputs: Book_Detail_Admin_EnqueuedInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`${i?.enqueued} enfileirados, ${i?.skipped} ignorados`)
};
const fr_book_detail_admin_enqueued = /** @type {(inputs: Book_Detail_Admin_EnqueuedInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`${i?.enqueued} en file, ${i?.skipped} ignorés`)
};
/**
* | output |
* | --- |
* | "Enqueued {enqueued}, skipped {skipped}" |
*
* @param {Book_Detail_Admin_EnqueuedInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_enqueued = /** @type {((inputs: Book_Detail_Admin_EnqueuedInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_EnqueuedInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_enqueued(inputs)
if (locale === "ru") return ru_book_detail_admin_enqueued(inputs)
if (locale === "id") return id_book_detail_admin_enqueued(inputs)
if (locale === "pt") return pt_book_detail_admin_enqueued(inputs)
return fr_book_detail_admin_enqueued(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_GenerateInputs */
const en_book_detail_admin_generate = /** @type {(inputs: Book_Detail_Admin_GenerateInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Generate`)
};
const ru_book_detail_admin_generate = /** @type {(inputs: Book_Detail_Admin_GenerateInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Сгенерировать`)
};
const id_book_detail_admin_generate = /** @type {(inputs: Book_Detail_Admin_GenerateInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Buat`)
};
const pt_book_detail_admin_generate = /** @type {(inputs: Book_Detail_Admin_GenerateInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gerar`)
};
const fr_book_detail_admin_generate = /** @type {(inputs: Book_Detail_Admin_GenerateInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Générer`)
};
/**
* | output |
* | --- |
* | "Generate" |
*
* @param {Book_Detail_Admin_GenerateInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_generate = /** @type {((inputs?: Book_Detail_Admin_GenerateInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_GenerateInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_generate(inputs)
if (locale === "ru") return ru_book_detail_admin_generate(inputs)
if (locale === "id") return id_book_detail_admin_generate(inputs)
if (locale === "pt") return pt_book_detail_admin_generate(inputs)
return fr_book_detail_admin_generate(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_Save_CoverInputs */
const en_book_detail_admin_save_cover = /** @type {(inputs: Book_Detail_Admin_Save_CoverInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Save Cover`)
};
const ru_book_detail_admin_save_cover = /** @type {(inputs: Book_Detail_Admin_Save_CoverInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Сохранить обложку`)
};
const id_book_detail_admin_save_cover = /** @type {(inputs: Book_Detail_Admin_Save_CoverInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Simpan Sampul`)
};
const pt_book_detail_admin_save_cover = /** @type {(inputs: Book_Detail_Admin_Save_CoverInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Salvar Capa`)
};
const fr_book_detail_admin_save_cover = /** @type {(inputs: Book_Detail_Admin_Save_CoverInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Enregistrer la couverture`)
};
/**
* | output |
* | --- |
* | "Save Cover" |
*
* @param {Book_Detail_Admin_Save_CoverInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_save_cover = /** @type {((inputs?: Book_Detail_Admin_Save_CoverInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_Save_CoverInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_save_cover(inputs)
if (locale === "ru") return ru_book_detail_admin_save_cover(inputs)
if (locale === "id") return id_book_detail_admin_save_cover(inputs)
if (locale === "pt") return pt_book_detail_admin_save_cover(inputs)
return fr_book_detail_admin_save_cover(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_SavedInputs */
const en_book_detail_admin_saved = /** @type {(inputs: Book_Detail_Admin_SavedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Saved`)
};
const ru_book_detail_admin_saved = /** @type {(inputs: Book_Detail_Admin_SavedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Сохранено`)
};
const id_book_detail_admin_saved = /** @type {(inputs: Book_Detail_Admin_SavedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tersimpan`)
};
const pt_book_detail_admin_saved = /** @type {(inputs: Book_Detail_Admin_SavedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Salvo`)
};
const fr_book_detail_admin_saved = /** @type {(inputs: Book_Detail_Admin_SavedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Enregistré`)
};
/**
* | output |
* | --- |
* | "Saved" |
*
* @param {Book_Detail_Admin_SavedInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_saved = /** @type {((inputs?: Book_Detail_Admin_SavedInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_SavedInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_saved(inputs)
if (locale === "ru") return ru_book_detail_admin_saved(inputs)
if (locale === "id") return id_book_detail_admin_saved(inputs)
if (locale === "pt") return pt_book_detail_admin_saved(inputs)
return fr_book_detail_admin_saved(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_SavingInputs */
const en_book_detail_admin_saving = /** @type {(inputs: Book_Detail_Admin_SavingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Saving…`)
};
const ru_book_detail_admin_saving = /** @type {(inputs: Book_Detail_Admin_SavingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Сохранение…`)
};
const id_book_detail_admin_saving = /** @type {(inputs: Book_Detail_Admin_SavingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Menyimpan…`)
};
const pt_book_detail_admin_saving = /** @type {(inputs: Book_Detail_Admin_SavingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Salvando…`)
};
const fr_book_detail_admin_saving = /** @type {(inputs: Book_Detail_Admin_SavingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Enregistrement…`)
};
/**
* | output |
* | --- |
* | "Saving…" |
*
* @param {Book_Detail_Admin_SavingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_saving = /** @type {((inputs?: Book_Detail_Admin_SavingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_SavingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_saving(inputs)
if (locale === "ru") return ru_book_detail_admin_saving(inputs)
if (locale === "id") return id_book_detail_admin_saving(inputs)
if (locale === "pt") return pt_book_detail_admin_saving(inputs)
return fr_book_detail_admin_saving(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Book_Detail_Admin_VoiceInputs */
const en_book_detail_admin_voice = /** @type {(inputs: Book_Detail_Admin_VoiceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Voice`)
};
const ru_book_detail_admin_voice = /** @type {(inputs: Book_Detail_Admin_VoiceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Голос`)
};
const id_book_detail_admin_voice = /** @type {(inputs: Book_Detail_Admin_VoiceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Suara`)
};
const pt_book_detail_admin_voice = /** @type {(inputs: Book_Detail_Admin_VoiceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Voz`)
};
const fr_book_detail_admin_voice = /** @type {(inputs: Book_Detail_Admin_VoiceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Voix`)
};
/**
* | output |
* | --- |
* | "Voice" |
*
* @param {Book_Detail_Admin_VoiceInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const book_detail_admin_voice = /** @type {((inputs?: Book_Detail_Admin_VoiceInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Book_Detail_Admin_VoiceInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_book_detail_admin_voice(inputs)
if (locale === "ru") return ru_book_detail_admin_voice(inputs)
if (locale === "id") return id_book_detail_admin_voice(inputs)
if (locale === "pt") return pt_book_detail_admin_voice(inputs)
return fr_book_detail_admin_voice(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Already_ProInputs */
const en_subscribe_already_pro = /** @type {(inputs: Subscribe_Already_ProInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`You already have a Pro subscription.`)
};
const ru_subscribe_already_pro = /** @type {(inputs: Subscribe_Already_ProInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`У вас уже есть подписка Pro.`)
};
const id_subscribe_already_pro = /** @type {(inputs: Subscribe_Already_ProInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Anda sudah berlangganan Pro.`)
};
const pt_subscribe_already_pro = /** @type {(inputs: Subscribe_Already_ProInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Você já tem uma assinatura Pro.`)
};
const fr_subscribe_already_pro = /** @type {(inputs: Subscribe_Already_ProInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Vous avez déjà un abonnement Pro.`)
};
/**
* | output |
* | --- |
* | "You already have a Pro subscription." |
*
* @param {Subscribe_Already_ProInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_already_pro = /** @type {((inputs?: Subscribe_Already_ProInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Already_ProInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_already_pro(inputs)
if (locale === "ru") return ru_subscribe_already_pro(inputs)
if (locale === "id") return id_subscribe_already_pro(inputs)
if (locale === "pt") return pt_subscribe_already_pro(inputs)
return fr_subscribe_already_pro(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Annual_LabelInputs */
const en_subscribe_annual_label = /** @type {(inputs: Subscribe_Annual_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Annual`)
};
const ru_subscribe_annual_label = /** @type {(inputs: Subscribe_Annual_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Ежегодно`)
};
const id_subscribe_annual_label = /** @type {(inputs: Subscribe_Annual_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tahunan`)
};
const pt_subscribe_annual_label = /** @type {(inputs: Subscribe_Annual_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Anual`)
};
const fr_subscribe_annual_label = /** @type {(inputs: Subscribe_Annual_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Annuel`)
};
/**
* | output |
* | --- |
* | "Annual" |
*
* @param {Subscribe_Annual_LabelInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_annual_label = /** @type {((inputs?: Subscribe_Annual_LabelInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Annual_LabelInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_annual_label(inputs)
if (locale === "ru") return ru_subscribe_annual_label(inputs)
if (locale === "id") return id_subscribe_annual_label(inputs)
if (locale === "pt") return pt_subscribe_annual_label(inputs)
return fr_subscribe_annual_label(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Annual_PeriodInputs */
const en_subscribe_annual_period = /** @type {(inputs: Subscribe_Annual_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`per year`)
};
const ru_subscribe_annual_period = /** @type {(inputs: Subscribe_Annual_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`в год`)
};
const id_subscribe_annual_period = /** @type {(inputs: Subscribe_Annual_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`per tahun`)
};
const pt_subscribe_annual_period = /** @type {(inputs: Subscribe_Annual_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`por ano`)
};
const fr_subscribe_annual_period = /** @type {(inputs: Subscribe_Annual_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`par an`)
};
/**
* | output |
* | --- |
* | "per year" |
*
* @param {Subscribe_Annual_PeriodInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_annual_period = /** @type {((inputs?: Subscribe_Annual_PeriodInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Annual_PeriodInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_annual_period(inputs)
if (locale === "ru") return ru_subscribe_annual_period(inputs)
if (locale === "id") return id_subscribe_annual_period(inputs)
if (locale === "pt") return pt_subscribe_annual_period(inputs)
return fr_subscribe_annual_period(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Annual_PriceInputs */
const en_subscribe_annual_price = /** @type {(inputs: Subscribe_Annual_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$48`)
};
const ru_subscribe_annual_price = /** @type {(inputs: Subscribe_Annual_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$48`)
};
const id_subscribe_annual_price = /** @type {(inputs: Subscribe_Annual_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$48`)
};
const pt_subscribe_annual_price = /** @type {(inputs: Subscribe_Annual_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$48`)
};
const fr_subscribe_annual_price = /** @type {(inputs: Subscribe_Annual_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`48 $`)
};
/**
* | output |
* | --- |
* | "$48" |
*
* @param {Subscribe_Annual_PriceInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_annual_price = /** @type {((inputs?: Subscribe_Annual_PriceInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Annual_PriceInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_annual_price(inputs)
if (locale === "ru") return ru_subscribe_annual_price(inputs)
if (locale === "id") return id_subscribe_annual_price(inputs)
if (locale === "pt") return pt_subscribe_annual_price(inputs)
return fr_subscribe_annual_price(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Annual_SaveInputs */
const en_subscribe_annual_save = /** @type {(inputs: Subscribe_Annual_SaveInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Save 33%`)
};
const ru_subscribe_annual_save = /** @type {(inputs: Subscribe_Annual_SaveInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Сэкономьте 33%`)
};
const id_subscribe_annual_save = /** @type {(inputs: Subscribe_Annual_SaveInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Hemat 33%`)
};
const pt_subscribe_annual_save = /** @type {(inputs: Subscribe_Annual_SaveInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Economize 33%`)
};
const fr_subscribe_annual_save = /** @type {(inputs: Subscribe_Annual_SaveInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Économisez 33 %`)
};
/**
* | output |
* | --- |
* | "Save 33%" |
*
* @param {Subscribe_Annual_SaveInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_annual_save = /** @type {((inputs?: Subscribe_Annual_SaveInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Annual_SaveInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_annual_save(inputs)
if (locale === "ru") return ru_subscribe_annual_save(inputs)
if (locale === "id") return id_subscribe_annual_save(inputs)
if (locale === "pt") return pt_subscribe_annual_save(inputs)
return fr_subscribe_annual_save(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Benefit_AudioInputs */
const en_subscribe_benefit_audio = /** @type {(inputs: Subscribe_Benefit_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Unlimited audio chapters per day`)
};
const ru_subscribe_benefit_audio = /** @type {(inputs: Subscribe_Benefit_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Неограниченные аудиоглавы в день`)
};
const id_subscribe_benefit_audio = /** @type {(inputs: Subscribe_Benefit_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Bab audio tak terbatas per hari`)
};
const pt_subscribe_benefit_audio = /** @type {(inputs: Subscribe_Benefit_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Capítulos de áudio ilimitados por dia`)
};
const fr_subscribe_benefit_audio = /** @type {(inputs: Subscribe_Benefit_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Chapitres audio illimités par jour`)
};
/**
* | output |
* | --- |
* | "Unlimited audio chapters per day" |
*
* @param {Subscribe_Benefit_AudioInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_benefit_audio = /** @type {((inputs?: Subscribe_Benefit_AudioInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Benefit_AudioInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_benefit_audio(inputs)
if (locale === "ru") return ru_subscribe_benefit_audio(inputs)
if (locale === "id") return id_subscribe_benefit_audio(inputs)
if (locale === "pt") return pt_subscribe_benefit_audio(inputs)
return fr_subscribe_benefit_audio(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Benefit_DownloadsInputs */
const en_subscribe_benefit_downloads = /** @type {(inputs: Subscribe_Benefit_DownloadsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Download chapters for offline listening`)
};
const ru_subscribe_benefit_downloads = /** @type {(inputs: Subscribe_Benefit_DownloadsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Скачивайте главы для прослушивания офлайн`)
};
const id_subscribe_benefit_downloads = /** @type {(inputs: Subscribe_Benefit_DownloadsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Unduh bab untuk didengarkan secara offline`)
};
const pt_subscribe_benefit_downloads = /** @type {(inputs: Subscribe_Benefit_DownloadsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Baixe capítulos para ouvir offline`)
};
const fr_subscribe_benefit_downloads = /** @type {(inputs: Subscribe_Benefit_DownloadsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Télécharger des chapitres pour une écoute hors ligne`)
};
/**
* | output |
* | --- |
* | "Download chapters for offline listening" |
*
* @param {Subscribe_Benefit_DownloadsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_benefit_downloads = /** @type {((inputs?: Subscribe_Benefit_DownloadsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Benefit_DownloadsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_benefit_downloads(inputs)
if (locale === "ru") return ru_subscribe_benefit_downloads(inputs)
if (locale === "id") return id_subscribe_benefit_downloads(inputs)
if (locale === "pt") return pt_subscribe_benefit_downloads(inputs)
return fr_subscribe_benefit_downloads(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Benefit_TranslationInputs */
const en_subscribe_benefit_translation = /** @type {(inputs: Subscribe_Benefit_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Read in French, Indonesian, Portuguese, and Russian`)
};
const ru_subscribe_benefit_translation = /** @type {(inputs: Subscribe_Benefit_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Читайте на французском, индонезийском, португальском и русском`)
};
const id_subscribe_benefit_translation = /** @type {(inputs: Subscribe_Benefit_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Baca dalam bahasa Prancis, Indonesia, Portugis, dan Rusia`)
};
const pt_subscribe_benefit_translation = /** @type {(inputs: Subscribe_Benefit_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Leia em francês, indonésio, português e russo`)
};
const fr_subscribe_benefit_translation = /** @type {(inputs: Subscribe_Benefit_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Lire en français, indonésien, portugais et russe`)
};
/**
* | output |
* | --- |
* | "Read in French, Indonesian, Portuguese, and Russian" |
*
* @param {Subscribe_Benefit_TranslationInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_benefit_translation = /** @type {((inputs?: Subscribe_Benefit_TranslationInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Benefit_TranslationInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_benefit_translation(inputs)
if (locale === "ru") return ru_subscribe_benefit_translation(inputs)
if (locale === "id") return id_subscribe_benefit_translation(inputs)
if (locale === "pt") return pt_subscribe_benefit_translation(inputs)
return fr_subscribe_benefit_translation(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Benefit_VoicesInputs */
const en_subscribe_benefit_voices = /** @type {(inputs: Subscribe_Benefit_VoicesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Voice selection across all TTS engines`)
};
const ru_subscribe_benefit_voices = /** @type {(inputs: Subscribe_Benefit_VoicesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Выбор голоса для всех TTS-движков`)
};
const id_subscribe_benefit_voices = /** @type {(inputs: Subscribe_Benefit_VoicesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Pilihan suara untuk semua mesin TTS`)
};
const pt_subscribe_benefit_voices = /** @type {(inputs: Subscribe_Benefit_VoicesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Seleção de voz para todos os mecanismos TTS`)
};
const fr_subscribe_benefit_voices = /** @type {(inputs: Subscribe_Benefit_VoicesInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Sélection de voix pour tous les moteurs TTS`)
};
/**
* | output |
* | --- |
* | "Voice selection across all TTS engines" |
*
* @param {Subscribe_Benefit_VoicesInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_benefit_voices = /** @type {((inputs?: Subscribe_Benefit_VoicesInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Benefit_VoicesInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_benefit_voices(inputs)
if (locale === "ru") return ru_subscribe_benefit_voices(inputs)
if (locale === "id") return id_subscribe_benefit_voices(inputs)
if (locale === "pt") return pt_subscribe_benefit_voices(inputs)
return fr_subscribe_benefit_voices(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Cta_AnnualInputs */
const en_subscribe_cta_annual = /** @type {(inputs: Subscribe_Cta_AnnualInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Start annual plan`)
};
const ru_subscribe_cta_annual = /** @type {(inputs: Subscribe_Cta_AnnualInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Начать годовой план`)
};
const id_subscribe_cta_annual = /** @type {(inputs: Subscribe_Cta_AnnualInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mulai paket tahunan`)
};
const pt_subscribe_cta_annual = /** @type {(inputs: Subscribe_Cta_AnnualInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Começar plano anual`)
};
const fr_subscribe_cta_annual = /** @type {(inputs: Subscribe_Cta_AnnualInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Commencer le plan annuel`)
};
/**
* | output |
* | --- |
* | "Start annual plan" |
*
* @param {Subscribe_Cta_AnnualInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_cta_annual = /** @type {((inputs?: Subscribe_Cta_AnnualInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Cta_AnnualInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_cta_annual(inputs)
if (locale === "ru") return ru_subscribe_cta_annual(inputs)
if (locale === "id") return id_subscribe_cta_annual(inputs)
if (locale === "pt") return pt_subscribe_cta_annual(inputs)
return fr_subscribe_cta_annual(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Cta_MonthlyInputs */
const en_subscribe_cta_monthly = /** @type {(inputs: Subscribe_Cta_MonthlyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Start monthly plan`)
};
const ru_subscribe_cta_monthly = /** @type {(inputs: Subscribe_Cta_MonthlyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Начать месячный план`)
};
const id_subscribe_cta_monthly = /** @type {(inputs: Subscribe_Cta_MonthlyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mulai paket bulanan`)
};
const pt_subscribe_cta_monthly = /** @type {(inputs: Subscribe_Cta_MonthlyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Começar plano mensal`)
};
const fr_subscribe_cta_monthly = /** @type {(inputs: Subscribe_Cta_MonthlyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Commencer le plan mensuel`)
};
/**
* | output |
* | --- |
* | "Start monthly plan" |
*
* @param {Subscribe_Cta_MonthlyInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_cta_monthly = /** @type {((inputs?: Subscribe_Cta_MonthlyInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Cta_MonthlyInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_cta_monthly(inputs)
if (locale === "ru") return ru_subscribe_cta_monthly(inputs)
if (locale === "id") return id_subscribe_cta_monthly(inputs)
if (locale === "pt") return pt_subscribe_cta_monthly(inputs)
return fr_subscribe_cta_monthly(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_HeadingInputs */
const en_subscribe_heading = /** @type {(inputs: Subscribe_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Read more. Listen more.`)
};
const ru_subscribe_heading = /** @type {(inputs: Subscribe_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Читайте больше. Слушайте больше.`)
};
const id_subscribe_heading = /** @type {(inputs: Subscribe_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Baca lebih. Dengarkan lebih.`)
};
const pt_subscribe_heading = /** @type {(inputs: Subscribe_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Leia mais. Ouça mais.`)
};
const fr_subscribe_heading = /** @type {(inputs: Subscribe_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Lisez plus. Écoutez plus.`)
};
/**
* | output |
* | --- |
* | "Read more. Listen more." |
*
* @param {Subscribe_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_heading = /** @type {((inputs?: Subscribe_HeadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_HeadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_heading(inputs)
if (locale === "ru") return ru_subscribe_heading(inputs)
if (locale === "id") return id_subscribe_heading(inputs)
if (locale === "pt") return pt_subscribe_heading(inputs)
return fr_subscribe_heading(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Login_CtaInputs */
const en_subscribe_login_cta = /** @type {(inputs: Subscribe_Login_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Sign in`)
};
const ru_subscribe_login_cta = /** @type {(inputs: Subscribe_Login_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Войти`)
};
const id_subscribe_login_cta = /** @type {(inputs: Subscribe_Login_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Masuk`)
};
const pt_subscribe_login_cta = /** @type {(inputs: Subscribe_Login_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Entrar`)
};
const fr_subscribe_login_cta = /** @type {(inputs: Subscribe_Login_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Se connecter`)
};
/**
* | output |
* | --- |
* | "Sign in" |
*
* @param {Subscribe_Login_CtaInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_login_cta = /** @type {((inputs?: Subscribe_Login_CtaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Login_CtaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_login_cta(inputs)
if (locale === "ru") return ru_subscribe_login_cta(inputs)
if (locale === "id") return id_subscribe_login_cta(inputs)
if (locale === "pt") return pt_subscribe_login_cta(inputs)
return fr_subscribe_login_cta(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Login_PromptInputs */
const en_subscribe_login_prompt = /** @type {(inputs: Subscribe_Login_PromptInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Sign in to subscribe`)
};
const ru_subscribe_login_prompt = /** @type {(inputs: Subscribe_Login_PromptInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Войдите, чтобы оформить подписку`)
};
const id_subscribe_login_prompt = /** @type {(inputs: Subscribe_Login_PromptInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Masuk untuk berlangganan`)
};
const pt_subscribe_login_prompt = /** @type {(inputs: Subscribe_Login_PromptInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Entre para assinar`)
};
const fr_subscribe_login_prompt = /** @type {(inputs: Subscribe_Login_PromptInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Connectez-vous pour vous abonner`)
};
/**
* | output |
* | --- |
* | "Sign in to subscribe" |
*
* @param {Subscribe_Login_PromptInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_login_prompt = /** @type {((inputs?: Subscribe_Login_PromptInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Login_PromptInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_login_prompt(inputs)
if (locale === "ru") return ru_subscribe_login_prompt(inputs)
if (locale === "id") return id_subscribe_login_prompt(inputs)
if (locale === "pt") return pt_subscribe_login_prompt(inputs)
return fr_subscribe_login_prompt(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_ManageInputs */
const en_subscribe_manage = /** @type {(inputs: Subscribe_ManageInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Manage subscription`)
};
const ru_subscribe_manage = /** @type {(inputs: Subscribe_ManageInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Управление подпиской`)
};
const id_subscribe_manage = /** @type {(inputs: Subscribe_ManageInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Kelola langganan`)
};
const pt_subscribe_manage = /** @type {(inputs: Subscribe_ManageInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gerenciar assinatura`)
};
const fr_subscribe_manage = /** @type {(inputs: Subscribe_ManageInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gérer l'abonnement`)
};
/**
* | output |
* | --- |
* | "Manage subscription" |
*
* @param {Subscribe_ManageInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_manage = /** @type {((inputs?: Subscribe_ManageInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_ManageInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_manage(inputs)
if (locale === "ru") return ru_subscribe_manage(inputs)
if (locale === "id") return id_subscribe_manage(inputs)
if (locale === "pt") return pt_subscribe_manage(inputs)
return fr_subscribe_manage(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Monthly_LabelInputs */
const en_subscribe_monthly_label = /** @type {(inputs: Subscribe_Monthly_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Monthly`)
};
const ru_subscribe_monthly_label = /** @type {(inputs: Subscribe_Monthly_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Ежемесячно`)
};
const id_subscribe_monthly_label = /** @type {(inputs: Subscribe_Monthly_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Bulanan`)
};
const pt_subscribe_monthly_label = /** @type {(inputs: Subscribe_Monthly_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mensal`)
};
const fr_subscribe_monthly_label = /** @type {(inputs: Subscribe_Monthly_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Mensuel`)
};
/**
* | output |
* | --- |
* | "Monthly" |
*
* @param {Subscribe_Monthly_LabelInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_monthly_label = /** @type {((inputs?: Subscribe_Monthly_LabelInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Monthly_LabelInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_monthly_label(inputs)
if (locale === "ru") return ru_subscribe_monthly_label(inputs)
if (locale === "id") return id_subscribe_monthly_label(inputs)
if (locale === "pt") return pt_subscribe_monthly_label(inputs)
return fr_subscribe_monthly_label(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Monthly_PeriodInputs */
const en_subscribe_monthly_period = /** @type {(inputs: Subscribe_Monthly_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`per month`)
};
const ru_subscribe_monthly_period = /** @type {(inputs: Subscribe_Monthly_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`в месяц`)
};
const id_subscribe_monthly_period = /** @type {(inputs: Subscribe_Monthly_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`per bulan`)
};
const pt_subscribe_monthly_period = /** @type {(inputs: Subscribe_Monthly_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`por mês`)
};
const fr_subscribe_monthly_period = /** @type {(inputs: Subscribe_Monthly_PeriodInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`par mois`)
};
/**
* | output |
* | --- |
* | "per month" |
*
* @param {Subscribe_Monthly_PeriodInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_monthly_period = /** @type {((inputs?: Subscribe_Monthly_PeriodInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Monthly_PeriodInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_monthly_period(inputs)
if (locale === "ru") return ru_subscribe_monthly_period(inputs)
if (locale === "id") return id_subscribe_monthly_period(inputs)
if (locale === "pt") return pt_subscribe_monthly_period(inputs)
return fr_subscribe_monthly_period(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Monthly_PriceInputs */
const en_subscribe_monthly_price = /** @type {(inputs: Subscribe_Monthly_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$6`)
};
const ru_subscribe_monthly_price = /** @type {(inputs: Subscribe_Monthly_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$6`)
};
const id_subscribe_monthly_price = /** @type {(inputs: Subscribe_Monthly_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$6`)
};
const pt_subscribe_monthly_price = /** @type {(inputs: Subscribe_Monthly_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`$6`)
};
const fr_subscribe_monthly_price = /** @type {(inputs: Subscribe_Monthly_PriceInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`6 $`)
};
/**
* | output |
* | --- |
* | "$6" |
*
* @param {Subscribe_Monthly_PriceInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_monthly_price = /** @type {((inputs?: Subscribe_Monthly_PriceInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Monthly_PriceInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_monthly_price(inputs)
if (locale === "ru") return ru_subscribe_monthly_price(inputs)
if (locale === "id") return id_subscribe_monthly_price(inputs)
if (locale === "pt") return pt_subscribe_monthly_price(inputs)
return fr_subscribe_monthly_price(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_Page_TitleInputs */
const en_subscribe_page_title = /** @type {(inputs: Subscribe_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Go Pro — libnovel`)
};
const ru_subscribe_page_title = /** @type {(inputs: Subscribe_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Перейти на Pro — libnovel`)
};
const id_subscribe_page_title = /** @type {(inputs: Subscribe_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Jadi Pro — libnovel`)
};
const pt_subscribe_page_title = /** @type {(inputs: Subscribe_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Seja Pro — libnovel`)
};
const fr_subscribe_page_title = /** @type {(inputs: Subscribe_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Passer Pro — libnovel`)
};
/**
* | output |
* | --- |
* | "Go Pro — libnovel" |
*
* @param {Subscribe_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_page_title = /** @type {((inputs?: Subscribe_Page_TitleInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_Page_TitleInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_page_title(inputs)
if (locale === "ru") return ru_subscribe_page_title(inputs)
if (locale === "id") return id_subscribe_page_title(inputs)
if (locale === "pt") return pt_subscribe_page_title(inputs)
return fr_subscribe_page_title(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Subscribe_SubheadingInputs */
const en_subscribe_subheading = /** @type {(inputs: Subscribe_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Upgrade to Pro and unlock the full libnovel experience.`)
};
const ru_subscribe_subheading = /** @type {(inputs: Subscribe_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Перейдите на Pro и откройте полный опыт libnovel.`)
};
const id_subscribe_subheading = /** @type {(inputs: Subscribe_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tingkatkan ke Pro dan buka pengalaman libnovel sepenuhnya.`)
};
const pt_subscribe_subheading = /** @type {(inputs: Subscribe_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Torne-se Pro e desbloqueie a experiência completa do libnovel.`)
};
const fr_subscribe_subheading = /** @type {(inputs: Subscribe_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Passez Pro et débloquez l'expérience libnovel complète.`)
};
/**
* | output |
* | --- |
* | "Upgrade to Pro and unlock the full libnovel experience." |
*
* @param {Subscribe_SubheadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const subscribe_subheading = /** @type {((inputs?: Subscribe_SubheadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Subscribe_SubheadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_subscribe_subheading(inputs)
if (locale === "ru") return ru_subscribe_subheading(inputs)
if (locale === "id") return id_subscribe_subheading(inputs)
if (locale === "pt") return pt_subscribe_subheading(inputs)
return fr_subscribe_subheading(inputs)
});

View File

@@ -28,7 +28,7 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
theme: row.theme ?? 'amber',
locale: row.locale ?? 'en',
fontFamily: row.font_family ?? 'system',
fontSize: row.font_size ?? 1.0
fontSize: row.font_size || 1.0
};
}
} catch (e) {

View File

@@ -54,8 +54,11 @@
let audioEl = $state<HTMLAudioElement | null>(null);
// ── Theme ──────────────────────────────────────────────────────────────
// svelte-ignore state_referenced_locally
let currentTheme = $state(data.settings?.theme ?? 'amber');
// svelte-ignore state_referenced_locally
let currentFontFamily = $state(data.settings?.fontFamily ?? 'system');
// svelte-ignore state_referenced_locally
let currentFontSize = $state(data.settings?.fontSize ?? 1.0);
// Expose theme + font state to child pages (e.g. profile picker)
@@ -100,7 +103,7 @@
// 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;
currentFontSize = data.settings.fontSize || 1.0;
// Mark dirty only after the synchronous apply is done so the save
// effect doesn't fire for this initial load.
setTimeout(() => { settingsDirty = true; }, 0);
@@ -192,6 +195,50 @@
return () => clearTimeout(id);
});
// ── MediaSession action handlers ────────────────────────────────────────────
// Without explicit handlers, iOS Safari loses lock-screen resume ability after
// ~1 minute of pause because it falls back to its own default which doesn't
// satisfy the user-gesture requirement for <audio>.play().
// Handlers registered here call audioEl directly so iOS trusts the gesture.
$effect(() => {
if (typeof navigator === 'undefined' || !('mediaSession' in navigator) || !audioEl) return;
const el = audioEl; // capture for closure
navigator.mediaSession.setActionHandler('play', () => {
el.play().catch(() => {});
});
navigator.mediaSession.setActionHandler('pause', () => {
el.pause();
});
navigator.mediaSession.setActionHandler('seekbackward', (d) => {
el.currentTime = Math.max(0, el.currentTime - (d.seekOffset ?? 15));
});
navigator.mediaSession.setActionHandler('seekforward', (d) => {
el.currentTime = Math.min(el.duration || 0, el.currentTime + (d.seekOffset ?? 30));
});
// previoustrack / nexttrack fall back to skip ±30s if no chapter nav available
try {
navigator.mediaSession.setActionHandler('previoustrack', () => {
el.currentTime = Math.max(0, el.currentTime - 30);
});
navigator.mediaSession.setActionHandler('nexttrack', () => {
el.currentTime = Math.min(el.duration || 0, el.currentTime + 30);
});
} catch { /* some browsers don't support these */ }
return () => {
(['play', 'pause', 'seekbackward', 'seekforward'] as MediaSessionAction[]).forEach((a) => {
try { navigator.mediaSession.setActionHandler(a, null); } catch { /* ignore */ }
});
};
});
// Keep playbackState in sync so iOS lock screen shows the right button state
$effect(() => {
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
navigator.mediaSession.playbackState = audioStore.isPlaying ? 'playing' : 'paused';
});
// ── Save audio time on pause/end (debounced 2s) ─────────────────────────
let audioTimeSaveTimer = 0;
function saveAudioTime() {
@@ -363,6 +410,15 @@
>
{m.nav_catalogue()}
</a>
{#if !data.isPro}
<a
href="/subscribe"
class="hidden sm:inline-flex items-center gap-1 text-sm font-semibold transition-colors {page.url.pathname.startsWith('/subscribe') ? 'text-(--color-brand)' : 'text-(--color-brand) opacity-70 hover:opacity-100'}"
>
<svg class="w-3.5 h-3.5 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>
Pro
</a>
{/if}
<div class="ml-auto flex items-center gap-2">
<!-- Theme dropdown (desktop) -->
<div class="hidden sm:block relative">

View File

@@ -6,7 +6,10 @@
{ href: '/admin/scrape', label: () => m.admin_nav_scrape() },
{ href: '/admin/audio', label: () => m.admin_nav_audio() },
{ href: '/admin/translation', label: () => m.admin_nav_translation() },
{ href: '/admin/changelog', label: () => m.admin_nav_changelog() }
{ href: '/admin/changelog', label: () => m.admin_nav_changelog() },
{ href: '/admin/image-gen', label: () => m.admin_nav_image_gen() },
{ href: '/admin/text-gen', label: () => m.admin_nav_text_gen() },
{ href: '/admin/catalogue-tools', label: () => m.admin_nav_catalogue_tools() }
];
const externalLinks = [
@@ -16,6 +19,7 @@
{ href: 'https://logs.libnovel.cc', label: () => m.admin_nav_logs() },
{ href: 'https://uptime.libnovel.cc', label: () => m.admin_nav_uptime() },
{ href: 'https://push.libnovel.cc', label: () => m.admin_nav_push() },
{ href: 'https://grafana.libnovel.cc', label: () => m.admin_nav_grafana() },
{ href: 'https://gitea.kalekber.cc/kamil/libnovel', label: () => m.admin_nav_gitea() }
];

View File

@@ -0,0 +1,24 @@
import type { PageServerLoad } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
export interface ImageModelInfo {
id: string;
label: string;
provider: string;
}
export const load: PageServerLoad = async () => {
// Parent layout already guards admin role.
let imgModels: ImageModelInfo[] = [];
try {
const res = await backendFetch('/api/admin/image-gen/models');
if (res.ok) {
const data = await res.json();
imgModels = (data.models ?? []) as ImageModelInfo[];
}
} catch (e) {
log.warn('admin/catalogue-tools', 'failed to load image models', { err: String(e) });
}
return { imgModels };
};

View File

@@ -0,0 +1,258 @@
<script lang="ts">
import { browser } from '$app/environment';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// svelte-ignore state_referenced_locally
const imgModels = data.imgModels ?? [];
// ── Config persistence ────────────────────────────────────────────────────────
const CONFIG_KEY = 'admin_catalogue_tools_v1';
interface SavedConfig {
imgModel: string;
numSteps: number;
width: number;
height: number;
}
function loadConfig(): Partial<SavedConfig> {
if (!browser) return {};
try {
const raw = localStorage.getItem(CONFIG_KEY);
return raw ? (JSON.parse(raw) as Partial<SavedConfig>) : {};
} catch { return {}; }
}
function saveConfig() {
if (!browser) return;
localStorage.setItem(CONFIG_KEY, JSON.stringify({ imgModel, numSteps, width, height }));
}
const saved = loadConfig();
let imgModel = $state(saved.imgModel ?? (imgModels[0]?.id ?? ''));
let numSteps = $state(saved.numSteps ?? 20);
let width = $state(saved.width ?? 0);
let height = $state(saved.height ?? 0);
$effect(() => { void imgModel; void numSteps; void width; void height; saveConfig(); });
// ── Batch covers ──────────────────────────────────────────────────────────────
let running = $state(false);
let jobID = $state('');
let done = $state(0);
let total = $state(0);
let events = $state<{ slug: string; skipped?: boolean; error?: string }[]>([]);
let finished = $state(false);
let error = $state('');
let cancelling = $state(false);
let progress = $derived(total > 0 ? Math.round((done / total) * 100) : 0);
async function startBatch() {
running = true; finished = false; error = ''; done = 0; total = 0; events = []; jobID = ''; cancelling = false;
try {
const res = await fetch('/api/admin/catalogue/batch-covers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: imgModel || undefined,
num_steps: numSteps || undefined,
width: width || undefined,
height: height || undefined,
})
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
error = body.error ?? `Error ${res.status}`;
return;
}
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = '';
outer: while (true) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6).trim();
if (!payload) continue;
let evt: {
job_id?: string;
done?: number;
total?: number;
slug?: string;
skipped?: boolean;
error?: string;
finish?: boolean;
};
try { evt = JSON.parse(payload); } catch { continue; }
if (evt.job_id) jobID = evt.job_id;
if (evt.total != null) total = evt.total;
if (evt.done != null) done = evt.done;
if (evt.finish) { finished = true; running = false; break outer; }
if (evt.slug) {
events = [{ slug: evt.slug, skipped: evt.skipped, error: evt.error }, ...events].slice(0, 200);
}
}
}
} catch (e) {
error = String(e);
} finally {
running = false;
}
}
async function cancelBatch() {
if (!jobID) return;
cancelling = true;
try {
await fetch('/api/admin/catalogue/batch-covers/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: jobID })
});
} catch { /* ignore */ } finally {
cancelling = false;
}
}
</script>
<svelte:head>
<title>Catalogue Tools — Admin</title>
</svelte:head>
<div class="space-y-8 max-w-4xl">
<div>
<h1 class="text-2xl font-bold text-(--color-text)">Catalogue Tools</h1>
<p class="text-(--color-muted) text-sm mt-1">Bulk AI operations for your book catalogue.</p>
</div>
<!-- Batch cover generation -->
<div class="space-y-4">
<h2 class="text-lg font-semibold text-(--color-text)">Batch Cover Generation</h2>
<p class="text-sm text-(--color-muted)">
Generates AI covers for every book that has no cover stored in MinIO.
Books with existing covers are skipped. The job can be cancelled at any time.
</p>
<!-- Config -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 bg-(--color-surface) border border-(--color-border) rounded-xl p-4">
{#if imgModels.length > 0}
<div class="col-span-2 sm:col-span-4 space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="img-model">Image model</label>
<select id="img-model" bind:value={imgModel}
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 imgModels as m}
<option value={m.id}>{m.label} {m.provider}</option>
{/each}
</select>
</div>
{/if}
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="steps">Steps</label>
<input id="steps" type="number" bind:value={numSteps} min="1" max="50"
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 class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="width">Width <span class="font-normal">(0=default)</span></label>
<input id="width" type="number" bind:value={width} min="0" step="64"
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 class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="height">Height <span class="font-normal">(0=default)</span></label>
<input id="height" type="number" bind:value={height} min="0" step="64"
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>
<!-- Controls -->
<div class="flex gap-3">
<button
onclick={startBatch}
disabled={running}
class="px-6 py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{#if running}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
Running…
{:else}
Start batch
{/if}
</button>
{#if running && jobID}
<button
onclick={cancelBatch}
disabled={cancelling}
class="px-5 py-2.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) text-sm
hover:text-(--color-text) transition-colors disabled:opacity-50"
>
{cancelling ? 'Cancelling…' : 'Cancel'}
</button>
{/if}
</div>
{#if error}
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{error}</p>
{/if}
<!-- Progress -->
{#if total > 0 || running}
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-(--color-muted)">
{done} / {total} books processed
{#if finished} — done{/if}
</span>
<span class="text-(--color-muted)">{progress}%</span>
</div>
<div class="w-full h-2 bg-(--color-surface-2) rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300 {finished ? 'bg-green-500' : 'bg-(--color-brand)'}"
style="width: {progress}%"
></div>
</div>
</div>
{/if}
<!-- Event log -->
{#if events.length > 0}
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl overflow-hidden">
<div class="px-4 py-2 border-b border-(--color-border)">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Activity log (newest first)</p>
</div>
<div class="max-h-80 overflow-y-auto divide-y divide-(--color-border)">
{#each events as evt}
<div class="px-4 py-2 flex items-center gap-3 text-sm">
{#if evt.error}
<span class="w-2 h-2 rounded-full bg-(--color-danger) shrink-0"></span>
<span class="font-mono text-(--color-muted)">{evt.slug}</span>
<span class="text-(--color-danger) text-xs truncate">{evt.error}</span>
{:else if evt.skipped}
<span class="w-2 h-2 rounded-full bg-(--color-surface-2) shrink-0"></span>
<span class="font-mono text-(--color-muted)">{evt.slug}</span>
<span class="text-xs text-(--color-muted)">skipped (has cover)</span>
{:else}
<span class="w-2 h-2 rounded-full bg-green-500 shrink-0"></span>
<span class="font-mono text-(--color-text)">{evt.slug}</span>
<span class="text-xs text-green-400">generated</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,47 @@
import type { PageServerLoad } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
import { listBooks } from '$lib/server/pocketbase';
export interface ImageModelInfo {
id: string;
label: string;
provider: string;
supports_ref: boolean;
recommended_for: string[]; // "cover" | "chapter"
description: string;
}
export interface BookSummary {
slug: string;
title: string;
summary: string;
cover: string;
}
export const load: PageServerLoad = async () => {
// parent layout already guards admin role
const [modelsResult, books] = await Promise.allSettled([
(async () => {
const res = await backendFetch('/api/admin/image-gen/models');
if (!res.ok) throw new Error(`status ${res.status}`);
const data = await res.json();
return (data.models ?? []) as ImageModelInfo[];
})(),
listBooks()
]);
if (modelsResult.status === 'rejected') {
log.warn('admin/image-gen', 'failed to load models', { err: String(modelsResult.reason) });
}
return {
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as ImageModelInfo[]),
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
slug: b.slug,
title: b.title,
summary: b.summary ?? '',
cover: b.cover ?? ''
})) as BookSummary[]
};
};

View File

@@ -0,0 +1,907 @@
<script lang="ts">
import { browser } from '$app/environment';
import type { PageData } from './$types';
import type { ImageModelInfo, BookSummary } from './+page.server';
let { data }: { data: PageData } = $props();
// ── Form state ───────────────────────────────────────────────────────────────
type ImageType = 'cover' | 'chapter';
const CONFIG_KEY = 'admin_image_gen_config_v1';
interface SavedConfig {
selectedModel: string;
numSteps: number;
guidance: number;
strength: number;
width: number;
height: number;
showAdvanced: boolean;
}
function loadConfig(): Partial<SavedConfig> {
if (!browser) return {};
try {
const raw = localStorage.getItem(CONFIG_KEY);
return raw ? (JSON.parse(raw) as Partial<SavedConfig>) : {};
} catch { return {}; }
}
function saveConfig() {
if (!browser) return;
const cfg: SavedConfig = { selectedModel, numSteps, guidance, strength, width, height, showAdvanced };
localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg));
}
const saved = loadConfig();
let imageType = $state<ImageType>('cover');
let slug = $state('');
let chapter = $state<number>(1);
let selectedModel = $state(saved.selectedModel ?? '');
let prompt = $state('');
let referenceFile = $state<File | null>(null);
let referencePreviewUrl = $state('');
let useCoverAsRef = $state(false);
// Advanced
let showAdvanced = $state(saved.showAdvanced ?? false);
let numSteps = $state(saved.numSteps ?? 20);
let guidance = $state(saved.guidance ?? 7.5);
let strength = $state(saved.strength ?? 0.75);
let width = $state(saved.width ?? 1024);
let height = $state(saved.height ?? 1024);
// Persist config on change
$effect(() => {
void selectedModel; void numSteps; void guidance; void strength;
void width; void height; void showAdvanced;
saveConfig();
});
// ── Book autocomplete ────────────────────────────────────────────────────────
// svelte-ignore state_referenced_locally
const books: BookSummary[] = data.books ?? [];
let slugInput = $state('');
let slugFocused = $state(false);
let selectedBook = $state<BookSummary | null>(null);
let bookSuggestions = $derived(
slugInput.trim().length === 0
? []
: books
.filter((b) =>
b.slug.includes(slugInput.toLowerCase()) ||
b.title.toLowerCase().includes(slugInput.toLowerCase())
)
.slice(0, 8)
);
function selectBook(b: BookSummary) {
selectedBook = b;
slug = b.slug;
slugInput = b.slug;
slugFocused = false;
// Reset cover-as-ref if no cover
if (!b.cover) useCoverAsRef = false;
}
function onSlugInput() {
slug = slugInput;
// If user edits away from selected book slug, deselect
if (selectedBook && slugInput !== selectedBook.slug) {
selectedBook = null;
useCoverAsRef = false;
}
}
// When useCoverAsRef toggled on, load the book cover as reference
$effect(() => {
if (!browser) return;
if (!useCoverAsRef || !selectedBook?.cover) {
if (useCoverAsRef) useCoverAsRef = false;
return;
}
// Fetch the cover image and set as referenceFile
(async () => {
try {
const res = await fetch(selectedBook!.cover);
const blob = await res.blob();
const ext = blob.type === 'image/jpeg' ? 'jpg' : blob.type === 'image/webp' ? 'webp' : 'png';
const file = new File([blob], `${selectedBook!.slug}-cover.${ext}`, { type: blob.type });
handleReferenceFile(file);
} catch {
useCoverAsRef = false;
}
})();
});
// ── Generation state ─────────────────────────────────────────────────────────
let generating = $state(false);
let genError = $state('');
let elapsedMs = $state(0);
let elapsedInterval: ReturnType<typeof setInterval> | null = null;
// ── Result state ─────────────────────────────────────────────────────────────
interface GenResult {
imageSrc: string;
model: string;
bytes: number;
contentType: string;
saved: boolean;
coverUrl: string;
elapsedMs: number;
slug: string;
imageType: ImageType;
chapter: number;
}
let result = $state<GenResult | null>(null);
let history = $state<GenResult[]>([]);
let saving = $state(false);
let saveError = $state('');
let saveSuccess = $state(false);
// ── Model helpers ────────────────────────────────────────────────────────────
// svelte-ignore state_referenced_locally
const models: ImageModelInfo[] = data.models ?? [];
let coverModels = $derived(models.filter((m) => m.recommended_for.includes('cover')));
let chapterModels = $derived(models.filter((m) => m.recommended_for.includes('chapter')));
let otherModels = $derived(
models.filter(
(m) => !m.recommended_for.includes('cover') && !m.recommended_for.includes('chapter')
)
);
// ── Auto-select default model when type changes ──────────────────────────────
$effect(() => {
const preferred = imageType === 'cover' ? coverModels : chapterModels;
if (!selectedModel && preferred.length > 0) {
selectedModel = preferred[0].id;
}
});
$effect(() => {
void imageType;
const preferred = imageType === 'cover' ? coverModels : chapterModels;
if (preferred.length > 0) {
const current = models.find((m) => m.id === selectedModel);
if (!current || !current.recommended_for.includes(imageType)) {
selectedModel = preferred[0].id;
}
}
});
// ── Prompt templates ────────────────────────────────────────────────────────
let promptTemplate = $derived(
imageType === 'cover'
? `Book cover for "${slugInput || 'untitled novel'}", a fantasy adventure novel. Epic scene with dramatic lighting, professional book cover art, cinematic composition, highly detailed, 4K.`
: `Illustration for chapter ${chapter} of "${slugInput || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
);
function applyTemplate() {
prompt = promptTemplate;
}
function injectDescription() {
const desc = selectedBook?.summary?.trim();
if (!desc) return;
const snippet = desc.length > 300 ? desc.slice(0, 300) + '…' : desc;
prompt = prompt ? `${prompt}\n\nBook description: ${snippet}` : `Book description: ${snippet}`;
}
// ── Style presets ────────────────────────────────────────────────────────────
const PRESETS_KEY = 'admin_image_gen_presets_v1';
interface StylePreset {
name: string;
prompt: string;
}
function loadPresets(): StylePreset[] {
if (!browser) return [];
try {
const raw = localStorage.getItem(PRESETS_KEY);
return raw ? (JSON.parse(raw) as StylePreset[]) : [];
} catch { return []; }
}
function savePresets(p: StylePreset[]) {
if (!browser) return;
localStorage.setItem(PRESETS_KEY, JSON.stringify(p));
}
let presets = $state<StylePreset[]>(loadPresets());
let newPresetName = $state('');
let showPresets = $state(false);
function saveCurrentAsPreset() {
const name = newPresetName.trim();
if (!name || !prompt.trim()) return;
const existing = presets.findIndex((p) => p.name === name);
const updated = [...presets];
if (existing >= 0) updated[existing] = { name, prompt };
else updated.push({ name, prompt });
presets = updated;
savePresets(updated);
newPresetName = '';
}
function applyPreset(p: StylePreset) {
prompt = p.prompt;
showPresets = false;
}
function deletePreset(name: string) {
const updated = presets.filter((p) => p.name !== name);
presets = updated;
savePresets(updated);
}
// ── Reference image handling ─────────────────────────────────────────────────
let dragOver = $state(false);
function handleReferenceFile(file: File | null) {
referenceFile = file;
if (referencePreviewUrl) URL.revokeObjectURL(referencePreviewUrl);
referencePreviewUrl = file ? URL.createObjectURL(file) : '';
}
function onFileInput(e: Event) {
const input = e.target as HTMLInputElement;
handleReferenceFile(input.files?.[0] ?? null);
useCoverAsRef = false;
}
function onDrop(e: DragEvent) {
e.preventDefault();
dragOver = false;
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith('image/')) {
handleReferenceFile(file);
useCoverAsRef = false;
}
}
function clearReference() {
handleReferenceFile(null);
useCoverAsRef = false;
const input = document.getElementById('ref-file-input') as HTMLInputElement | null;
if (input) input.value = '';
}
// ── Selected model info ──────────────────────────────────────────────────────
let selectedModelInfo = $derived(models.find((m) => m.id === selectedModel) ?? null);
let refWarning = $derived(
referenceFile && selectedModelInfo && !selectedModelInfo.supports_ref
? `${selectedModelInfo.label} does not support reference images. The reference will be ignored.`
: ''
);
// ── Generate ────────────────────────────────────────────────────────────────
let canGenerate = $derived(prompt.trim().length > 0 && slug.trim().length > 0 && !generating);
async function generate() {
if (!canGenerate) return;
generating = true;
genError = '';
result = null;
elapsedMs = 0;
saveSuccess = false;
saveError = '';
const startTs = Date.now();
elapsedInterval = setInterval(() => {
elapsedMs = Date.now() - startTs;
}, 200);
try {
const payload = {
prompt: prompt.trim(),
model: selectedModel,
type: imageType,
slug: slug.trim(),
chapter: imageType === 'chapter' ? chapter : 0,
num_steps: numSteps,
guidance,
strength,
width,
height
};
let res: Response;
if (referenceFile && selectedModelInfo?.supports_ref) {
const fd = new FormData();
fd.append('json', JSON.stringify(payload));
fd.append('reference', referenceFile);
res = await fetch('/api/admin/image-gen', { method: 'POST', body: fd });
} else {
res = await fetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
const body = await res.json().catch(() => ({}));
if (!res.ok) {
genError = body.error ?? body.message ?? `Error ${res.status}`;
return;
}
const totalMs = Date.now() - startTs;
const newResult: GenResult = {
imageSrc: `data:${body.content_type};base64,${body.image_b64}`,
model: body.model,
bytes: body.bytes,
contentType: body.content_type,
saved: body.saved ?? false,
coverUrl: body.cover_url ?? '',
elapsedMs: totalMs,
slug: slug.trim(),
imageType,
chapter
};
result = newResult;
history = [newResult, ...history].slice(0, 5);
} catch {
genError = 'Network error.';
} finally {
generating = false;
if (elapsedInterval) {
clearInterval(elapsedInterval);
elapsedInterval = null;
}
}
}
// ── Save as cover ────────────────────────────────────────────────────────────
async function saveAsCover() {
if (!result || saving) return;
saving = true;
saveError = '';
saveSuccess = false;
try {
const b64 = result.imageSrc.split(',')[1];
const res = await fetch('/api/admin/image-gen/save-cover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: result.slug, image_b64: b64 })
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
saveError = body.error ?? body.message ?? `Error ${res.status}`;
return;
}
if (body.saved) {
saveSuccess = true;
result = { ...result, saved: true, coverUrl: body.cover_url ?? result.coverUrl };
} else {
saveError = 'Backend did not save the cover.';
}
} catch {
saveError = 'Network error.';
} finally {
saving = false;
}
}
// ── Download ─────────────────────────────────────────────────────────────────
function download() {
if (!result) return;
const a = document.createElement('a');
a.href = result.imageSrc;
const ext = result.contentType === 'image/jpeg' ? 'jpg' : 'png';
a.download =
result.imageType === 'cover'
? `${result.slug}-cover.${ext}`
: `${result.slug}-ch${result.chapter}.${ext}`;
a.click();
}
// ── Formatting helpers ───────────────────────────────────────────────────────
function fmtElapsed(ms: number) {
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
function fmtBytes(b: number) {
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
return `${(b / 1024 / 1024).toFixed(2)} MB`;
}
</script>
<svelte:head>
<title>Image Gen — Admin</title>
</svelte:head>
<div class="space-y-6 max-w-6xl">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-(--color-text)">Image Generation</h1>
<p class="text-(--color-muted) text-sm mt-1">
Generate book covers and chapter images using Cloudflare Workers AI.
</p>
</div>
<!-- Layout: form + result side by side on large screens -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<!-- ── Left: Form panel ──────────────────────────────────────────────────── -->
<div class="space-y-4">
<!-- Type toggle -->
<div class="flex gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
{#each (['cover', 'chapter'] as const) as t}
<button
onclick={() => (imageType = t)}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{imageType === t
? 'bg-(--color-surface-3) text-(--color-text)'
: 'text-(--color-muted) hover:text-(--color-text)'}"
>
{t === 'cover' ? 'Cover' : 'Chapter Image'}
</button>
{/each}
</div>
<!-- Slug + chapter -->
<div class="flex gap-3">
<div class="flex-1 min-w-0 space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="slug-input">
Book slug
</label>
<!-- Autocomplete wrapper -->
<div class="relative">
<input
id="slug-input"
type="text"
bind:value={slugInput}
oninput={onSlugInput}
onfocus={() => (slugFocused = true)}
onblur={() => setTimeout(() => { slugFocused = false; }, 150)}
placeholder="e.g. shadow-slave"
autocomplete="off"
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)"
/>
{#if slugFocused && bookSuggestions.length > 0}
<ul class="absolute z-50 top-full left-0 right-0 mt-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl overflow-hidden max-h-56 overflow-y-auto">
{#each bookSuggestions as b}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
<li
role="option"
aria-selected={selectedBook?.slug === b.slug}
onmousedown={() => selectBook(b)}
class="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-(--color-surface-3) transition-colors"
>
{#if b.cover}
<img src={b.cover} alt="" class="w-8 h-10 object-cover rounded shrink-0" />
{:else}
<div class="w-8 h-10 rounded bg-(--color-surface-3) shrink-0"></div>
{/if}
<div class="min-w-0">
<p class="text-sm text-(--color-text) truncate">{b.title}</p>
<p class="text-xs text-(--color-muted) truncate font-mono">{b.slug}</p>
</div>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Book info pill when a book is selected -->
{#if selectedBook}
<div class="flex items-center gap-2 mt-1">
<span class="text-xs text-(--color-success) flex items-center gap-1">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
{selectedBook.title}
</span>
{#if selectedBook.summary}
<button
onclick={injectDescription}
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
title="Append book description to prompt"
>
+ inject description
</button>
{/if}
</div>
{/if}
</div>
{#if imageType === 'chapter'}
<div class="w-24 space-y-1 shrink-0">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="chapter-input">
Chapter
</label>
<input
id="chapter-input"
type="number"
bind:value={chapter}
min="1"
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>
{/if}
</div>
<!-- Model selector -->
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="model-select">
Model
</label>
<select
id="model-select"
bind:value={selectedModel}
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)"
>
{#if coverModels.length > 0}
<optgroup label="Recommended for covers">
{#each coverModels as m}
<option value={m.id}>
{m.label}{m.provider}{m.supports_ref ? ' ★ref' : ''}
</option>
{/each}
</optgroup>
{/if}
{#if chapterModels.length > 0}
<optgroup label="Recommended for chapters">
{#each chapterModels as m}
<option value={m.id}>
{m.label}{m.provider}{m.supports_ref ? ' ★ref' : ''}
</option>
{/each}
</optgroup>
{/if}
{#if otherModels.length > 0}
<optgroup label="All models">
{#each otherModels as m}
<option value={m.id}>
{m.label}{m.provider}{m.supports_ref ? ' ★ref' : ''}
</option>
{/each}
</optgroup>
{/if}
</select>
{#if selectedModelInfo}
<p class="text-xs text-(--color-muted)">{selectedModelInfo.description}</p>
{/if}
{#if refWarning}
<p class="text-xs text-amber-400">{refWarning}</p>
{/if}
</div>
<!-- Prompt -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="prompt-input">
Prompt
</label>
<div class="flex flex-wrap items-center gap-3">
{#if selectedBook?.summary}
<button onclick={injectDescription} class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">
Inject description
</button>
{/if}
<button onclick={applyTemplate} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">
Use template
</button>
{#if presets.length > 0}
<button onclick={() => (showPresets = !showPresets)} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">
Presets ({presets.length})
</button>
{/if}
</div>
</div>
{#if showPresets && presets.length > 0}
<div class="bg-(--color-surface) border border-(--color-border) rounded-lg p-3 space-y-1.5">
{#each presets as p}
<div class="flex items-center gap-2 group">
<button onclick={() => applyPreset(p)} class="flex-1 text-left text-sm text-(--color-text) hover:text-(--color-brand) transition-colors truncate" title={p.prompt}>{p.name}</button>
<button onclick={() => deletePreset(p.name)} class="text-xs text-(--color-muted) hover:text-(--color-danger) transition-colors opacity-0 group-hover:opacity-100">delete</button>
</div>
{/each}
</div>
{/if}
<textarea
id="prompt-input"
bind:value={prompt}
rows="5"
placeholder="Describe the image to generate…"
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) resize-y"
></textarea>
<div class="flex gap-2">
<input type="text" bind:value={newPresetName} placeholder="Preset name…"
class="flex-1 bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-(--color-text) text-xs placeholder-zinc-500 focus:outline-none focus:ring-1 focus:ring-(--color-brand)" />
<button onclick={saveCurrentAsPreset} disabled={!newPresetName.trim() || !prompt.trim()}
class="px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-xs text-(--color-muted) hover:text-(--color-text) transition-colors disabled:opacity-40 disabled:cursor-not-allowed">
Save preset
</button>
</div>
</div>
<!-- Reference image drop zone -->
<div class="space-y-1">
<div class="flex items-center justify-between">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">
Reference image <span class="normal-case font-normal text-(--color-muted)">(optional, img2img)</span>
</p>
{#if selectedBook?.cover && selectedModelInfo?.supports_ref}
<div class="flex items-center gap-1.5 cursor-pointer select-none">
<button
type="button"
role="switch"
aria-checked={useCoverAsRef}
aria-label="Use book cover as reference"
onclick={() => (useCoverAsRef = !useCoverAsRef)}
class="w-8 h-4 rounded-full transition-colors relative focus:outline-none focus:ring-1 focus:ring-(--color-brand) {useCoverAsRef ? 'bg-(--color-brand)' : 'bg-(--color-surface-3)'}"
>
<span class="absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white transition-transform {useCoverAsRef ? 'translate-x-4' : ''}"></span>
</button>
<span class="text-xs text-(--color-muted)">Use book cover</span>
</div>
{/if}
</div>
{#if referenceFile && referencePreviewUrl}
<div class="flex items-start gap-3 p-3 bg-(--color-surface-2) rounded-lg border border-(--color-border)">
<img
src={referencePreviewUrl}
alt="Reference"
class="w-16 h-16 object-cover rounded-md shrink-0 border border-(--color-border)"
/>
<div class="min-w-0 flex-1 space-y-0.5">
<p class="text-sm text-(--color-text) truncate">{referenceFile.name}</p>
<p class="text-xs text-(--color-muted)">{fmtBytes(referenceFile.size)}</p>
{#if useCoverAsRef}
<p class="text-xs text-(--color-brand)">Current book cover</p>
{/if}
</div>
<button
onclick={clearReference}
class="text-(--color-muted) hover:text-(--color-text) transition-colors shrink-0"
aria-label="Remove reference image"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{:else}
<!-- Drop zone -->
<label
class="flex flex-col items-center justify-center gap-2 p-4 border-2 border-dashed rounded-lg cursor-pointer transition-colors
{dragOver
? 'border-(--color-brand) bg-(--color-brand)/5'
: 'border-(--color-border) hover:border-(--color-brand)/50 hover:bg-(--color-surface-2)'}"
ondragover={(e) => { e.preventDefault(); dragOver = true; }}
ondragleave={() => { dragOver = false; }}
ondrop={onDrop}
>
<svg class="w-6 h-6 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="text-xs text-(--color-muted)">Drop image or <span class="text-(--color-brand)">click to browse</span></span>
<input
id="ref-file-input"
type="file"
accept="image/png,image/jpeg,image/webp"
onchange={onFileInput}
class="sr-only"
/>
</label>
{/if}
</div>
<!-- Advanced collapsible -->
<div class="border border-(--color-border) rounded-lg overflow-hidden">
<button
onclick={() => (showAdvanced = !showAdvanced)}
class="w-full flex items-center justify-between px-4 py-2.5 bg-(--color-surface-2) text-sm font-medium text-(--color-muted) hover:text-(--color-text) transition-colors"
>
Advanced options
<svg
class="w-4 h-4 transition-transform {showAdvanced ? '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 showAdvanced}
<div class="px-4 py-4 bg-(--color-surface) space-y-4">
<!-- num_steps -->
<div class="space-y-1">
<div class="flex justify-between">
<label for="img-steps" class="text-xs text-(--color-muted)">Steps</label>
<span class="text-xs text-(--color-text) font-mono">{numSteps}</span>
</div>
<input id="img-steps" type="range" min="1" max="20" step="1" bind:value={numSteps}
class="w-full accent-(--color-brand)" />
</div>
<!-- guidance -->
<div class="space-y-1">
<div class="flex justify-between">
<label for="img-guidance" class="text-xs text-(--color-muted)">Guidance</label>
<span class="text-xs text-(--color-text) font-mono">{guidance.toFixed(1)}</span>
</div>
<input id="img-guidance" type="range" min="1" max="20" step="0.5" bind:value={guidance}
class="w-full accent-(--color-brand)" />
</div>
<!-- strength (only when reference present) -->
{#if referenceFile}
<div class="space-y-1">
<div class="flex justify-between">
<label for="img-strength" class="text-xs text-(--color-muted)">Strength</label>
<span class="text-xs text-(--color-text) font-mono">{strength.toFixed(2)}</span>
</div>
<input id="img-strength" type="range" min="0" max="1" step="0.05" bind:value={strength}
class="w-full accent-(--color-brand)" />
<p class="text-xs text-(--color-muted)">0 = copy reference · 1 = ignore reference</p>
</div>
{/if}
<!-- width × height -->
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1">
<label class="text-xs text-(--color-muted)" for="width-input">Width</label>
<input id="width-input" type="number" min="256" max="2048" step="64" bind:value={width}
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm focus:outline-none focus:ring-1 focus:ring-(--color-brand)" />
</div>
<div class="space-y-1">
<label class="text-xs text-(--color-muted)" for="height-input">Height</label>
<input id="height-input" type="number" min="256" max="2048" step="64" bind:value={height}
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm focus:outline-none focus:ring-1 focus:ring-(--color-brand)" />
</div>
</div>
</div>
{/if}
</div>
<!-- Generate button -->
<button
onclick={generate}
disabled={!canGenerate}
class="w-full py-2.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
>
{#if generating}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
Generating… {fmtElapsed(elapsedMs)}
{:else}
Generate
{/if}
</button>
{#if genError}
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{genError}</p>
{/if}
</div>
<!-- ── Right: Result panel ────────────────────────────────────────────────── -->
<div class="space-y-4">
{#if result}
<div class="bg-(--color-surface) border border-(--color-border) rounded-xl overflow-hidden">
<!-- Image -->
<img
src={result.imageSrc}
alt=""
aria-label="Generated cover"
class="w-full object-contain max-h-[36rem] bg-zinc-950"
/>
<!-- Meta bar -->
<div class="px-4 py-3 border-t border-(--color-border) space-y-3">
<div class="grid grid-cols-3 gap-2 text-xs">
<div>
<p class="text-(--color-muted)">Model</p>
<p class="text-(--color-text) font-mono truncate" title={result.model}>
{models.find((m) => m.id === result!.model)?.label ?? result.model}
</p>
</div>
<div>
<p class="text-(--color-muted)">Size</p>
<p class="text-(--color-text)">{fmtBytes(result.bytes)}</p>
</div>
<div>
<p class="text-(--color-muted)">Time</p>
<p class="text-(--color-text)">{fmtElapsed(result.elapsedMs)}</p>
</div>
</div>
{#if result.saved}
<p class="text-xs text-green-400">
Cover saved &rarr;
<a href={result.coverUrl} target="_blank" rel="noopener noreferrer"
class="underline hover:text-green-300">{result.coverUrl}</a>
</p>
{/if}
{#if saveSuccess && !result.saved}
<p class="text-xs text-green-400">Cover saved successfully.</p>
{/if}
{#if saveError}
<p class="text-xs text-(--color-danger)">{saveError}</p>
{/if}
<!-- Actions -->
<div class="flex gap-2 flex-wrap">
<button
onclick={download}
class="flex-1 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) text-xs font-medium hover:bg-zinc-600 transition-colors"
>
Download
</button>
{#if result.imageType === 'cover'}
<button
onclick={saveAsCover}
disabled={saving || result.saved}
class="flex-1 px-3 py-1.5 rounded-md bg-(--color-brand) text-(--color-surface) text-xs font-semibold
hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50"
>
{saving ? 'Saving…' : result.saved ? 'Saved ✓' : 'Save as cover'}
</button>
{/if}
</div>
</div>
</div>
{:else if generating}
<!-- Placeholder while generating -->
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-80">
<div class="text-center space-y-3">
<svg class="w-8 h-8 animate-spin mx-auto text-(--color-brand)" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
<p class="text-sm text-(--color-muted)">Generating… {fmtElapsed(elapsedMs)}</p>
</div>
</div>
{:else}
<!-- Empty state -->
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-80">
<p class="text-sm text-(--color-muted)">Generated image will appear here</p>
</div>
{/if}
<!-- History thumbnails -->
{#if history.length > 0}
<div class="space-y-2">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Session history</p>
<div class="flex gap-2 flex-wrap">
{#each history as h, i}
<button
onclick={() => result = h}
class="relative rounded-md overflow-hidden border transition-colors shrink-0
{result === h ? 'border-(--color-brand)' : 'border-(--color-border) hover:border-(--color-brand)/50'}"
>
<img
src={h.imageSrc}
alt="History {i + 1}"
class="w-16 h-16 object-cover"
/>
{#if h.saved}
<span class="absolute bottom-0.5 right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border border-(--color-surface)"></span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,42 @@
import type { PageServerLoad } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
import { listBooks } from '$lib/server/pocketbase';
export interface BookSummary {
slug: string;
title: string;
}
export interface TextModelInfo {
id: string;
label: string;
provider: string;
context_size: number;
description: string;
}
export const load: PageServerLoad = async () => {
// Parent layout already guards admin role.
const [modelsResult, books] = await Promise.allSettled([
(async () => {
const res = await backendFetch('/api/admin/text-gen/models');
if (!res.ok) throw new Error(`status ${res.status}`);
const data = await res.json();
return (data.models ?? []) as TextModelInfo[];
})(),
listBooks()
]);
if (modelsResult.status === 'rejected') {
log.warn('admin/text-gen', 'failed to load models', { err: String(modelsResult.reason) });
}
return {
models: modelsResult.status === 'fulfilled' ? modelsResult.value : ([] as TextModelInfo[]),
books: (books.status === 'fulfilled' ? books.value : []).map((b) => ({
slug: b.slug,
title: b.title
})) as BookSummary[]
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
/**
* POST /api/admin/audio/bulk
*
* Admin-only proxy to the Go backend's audio bulk-enqueue endpoint.
* Body: { slug, voice?, from, to, skip_existing?, force? }
* Response 202: { enqueued, skipped, task_ids }
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/audio/bulk', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/audio/bulk', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,34 @@
/**
* POST /api/admin/audio/cancel-bulk
*
* Admin-only proxy to cancel all pending/running audio tasks for a slug.
* Body: { slug }
* Response 200: { cancelled }
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/audio/cancel-bulk', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/audio/cancel-bulk', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,43 @@
/**
* POST /api/admin/catalogue/batch-covers
*
* Admin-only SSE proxy to the Go backend's batch cover generation endpoint.
* Pipes the stream straight through so the browser can consume it without buffering.
*/
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/catalogue/batch-covers', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/catalogue/batch-covers', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return new Response(JSON.stringify(data), {
status: res.status,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response(res.body, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no'
}
});
};

View File

@@ -0,0 +1,24 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/catalogue/batch-covers/cancel', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/catalogue/batch-covers/cancel', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,41 @@
/**
* POST /api/admin/catalogue/refresh-metadata/[slug]
*
* Admin-only SSE proxy — re-generates description + cover for a single book.
*/
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ params, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
let res: Response;
try {
res = await backendFetch(`/api/admin/catalogue/refresh-metadata/${params.slug}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{}'
});
} catch (e) {
log.error('admin/catalogue/refresh-metadata', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return new Response(JSON.stringify(data), {
status: res.status,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response(res.body, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no'
}
});
};

View File

@@ -0,0 +1,33 @@
/**
* POST /api/admin/dedup-chapters/[slug]
*
* Admin-only proxy to the Go backend's dedup endpoint.
* Removes duplicate chapters_idx records for a book, keeping the latest
* record per chapter number. Returns { slug, deleted }.
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ params, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const { slug } = params;
let res: Response;
try {
res = await backendFetch(`/api/admin/dedup-chapters/${encodeURIComponent(slug)}`, {
method: 'POST'
});
} catch (e) {
log.error('admin/dedup-chapters', 'backend proxy error', { slug, err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,46 @@
/**
* POST /api/admin/image-gen
*
* Admin-only proxy to the Go backend's image generation endpoint.
* Transparently forwards the request body (JSON or multipart/form-data)
* and returns the JSON response containing the base64-encoded image.
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const ct = request.headers.get('content-type') ?? '';
let res: Response;
try {
if (ct.includes('multipart/form-data')) {
// Forward raw body bytes; let the backend parse multipart
const body = await request.arrayBuffer();
res = await backendFetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'content-type': ct },
body
});
} else {
const body = await request.text();
res = await backendFetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
}
} catch (e) {
log.error('admin/image-gen', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,35 @@
/**
* POST /api/admin/image-gen/save-cover
*
* Admin-only proxy: persists a pre-generated base64 image as a book cover in
* MinIO without re-calling Cloudflare AI.
*
* Body: { slug: string, image_b64: string }
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/image-gen/save-cover', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/image-gen/save-cover', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,50 @@
/**
* POST /api/admin/text-gen/chapter-names
*
* Admin-only proxy to the Go backend's chapter-name generation endpoint.
* The backend streams SSE events; this handler pipes the stream through
* directly so the browser can consume it without buffering.
*/
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const body = await request.text();
let res: Response;
try {
res = await backendFetch('/api/admin/text-gen/chapter-names', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/text-gen/chapter-names', 'backend proxy error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
// Non-2xx: the backend returned a JSON error before switching to SSE.
if (!res.ok) {
const data = await res.json().catch(() => ({}));
return new Response(JSON.stringify(data), {
status: res.status,
headers: { 'Content-Type': 'application/json' }
});
}
// Pipe the SSE stream straight through — do not buffer.
return new Response(res.body, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no'
}
});
};

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