Compare commits

...

23 Commits

Author SHA1 Message Date
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
Admin
aaa008ac99 feat: add Cloudflare AI TTS engine (aura-2-en) with voice grouping in UI
All checks were successful
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 46s
Release / Docker / backend (push) Successful in 2m45s
Release / Docker / runner (push) Successful in 2m53s
Release / Docker / ui (push) Successful in 2m5s
Release / Gitea Release (push) Successful in 41s
2026-04-04 11:12:55 +05:00
Admin
9806f0d894 feat: live Gitea changelog, Gitea sidebar link, release title/body extraction
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m23s
Release / Docker / runner (push) Successful in 2m48s
Release / Docker / ui (push) Successful in 2m5s
Release / Gitea Release (push) Successful in 21s
- Admin changelog page now fetches releases live from the public Gitea
  API (https://gitea.kalekber.cc) instead of a baked releases.json file
- Remove /static/releases.json from ui/.gitignore (no longer generated)
- Add Gitea to admin sidebar external tools (admin_nav_gitea i18n key,
  all 5 locales, compiled paraglide JS force-added)
- release.yaml: remove 'Fetch releases from Gitea API' step; extract
  release title and body from the tagged commit message for Gitea releases
2026-04-03 23:20:30 +05:00
Admin
e862135775 fix: commit missing paraglide feed message JS files (were gitignored, needed force-add)
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m23s
Release / Docker / runner (push) Successful in 2m35s
Release / Docker / ui (push) Successful in 1m57s
Release / Gitea Release (push) Successful in 20s
New feed_*.js and nav_feed.js outputs from npm run paraglide were not tracked
because src/lib/paraglide/.gitignore ignores everything by default. Force-add
them so CI can run svelte-check without running paraglide compile first.

Also include recentlyUpdatedBooks fallback improvements from working tree.
2026-04-03 23:13:46 +05:00
Admin
8588475d03 feat: in-reader settings, more like this, reading history, audio filter, feed page
Some checks failed
Release / Test backend (push) Successful in 38s
Release / Check ui (push) Failing after 33s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 51s
Release / Docker / backend (push) Successful in 2m20s
Release / Docker / runner (push) Successful in 2m28s
Release / Gitea Release (push) Has been skipped
- Add floating gear button + theme/font/size drawer in chapter reader
- Add 'More like this' horizontal scroll row on book detail page
- Add reading history tab on profile page (chronological progress timeline)
- Add audio-available badge + filter toggle on catalogue (GET /api/audio/slugs)
- Fix discover card entry animation pop (split entry vs snap-back cubic-bezier)
- Add dedicated /feed page showing books followed users are reading
- Add Feed nav link (desktop + mobile) in all 5 locales
2026-04-03 22:32:37 +05:00
Admin
28fee7aee3 feat: fix recently-updated section + hideable home sections
All checks were successful
Release / Test backend (push) Successful in 37s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 44s
Release / Docker / backend (push) Successful in 2m28s
Release / Docker / runner (push) Successful in 2m28s
Release / Docker / ui (push) Successful in 1m52s
Release / Gitea Release (push) Successful in 19s
Fix "Recently Updated" showing stale books: replace meta_updated
sorting (only changes on metadata writes) with chapters_idx sorted
by -created, so the section now reflects actual chapter activity.

Add per-section show/hide toggles on the home page, persisted in
localStorage via Svelte 5 $state. Each section header gets a small
hide button; hidden sections appear as restore chips above the footer.
Toggleable: Recently Updated, Browse by Genre, From Following.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:09:48 +05:00
Admin
a888d9a0f5 fix: clean up book detail mobile layout + make genre tags linkable
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 49s
Release / Docker / caddy (push) Successful in 53s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 2m22s
Release / Docker / ui (push) Successful in 1m52s
Release / Gitea Release (push) Successful in 19s
Mobile action area was cluttered with Continue, Start ch.1, bookmark,
stars, and shelf dropdown all competing in a single unstructured row.

- Split mobile CTAs into two clear rows:
  Row 1: primary read button(s) only (Continue / Start from ch.1)
  Row 2: bookmark icon + shelf dropdown + star rating inline
- 'Start from ch.1' no longer stretches to flex-1 when Continue is
  present — it's a compact secondary button instead
- Stars and shelf dropdown moved out of the CTA row into their own line

Genre tags were plain <span> elements with no interaction. Tapping
'fantasy' or 'action' now navigates to /catalogue?genre=fantasy,
pre-selecting the genre filter on the catalogue page.
2026-04-03 21:34:38 +05:00
Admin
ac7b686fba fix: don't save settings immediately after login
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 45s
Release / Docker / caddy (push) Successful in 45s
Release / Docker / backend (push) Successful in 2m53s
Release / Docker / runner (push) Successful in 2m34s
Release / Docker / ui (push) Successful in 1m56s
Release / Gitea Release (push) Successful in 20s
The save-settings $effect was firing on the initial data load because
settingsApplied was set to true synchronously in the apply effect, then
currentTheme/fontFamily/fontSize were written in the same tick — causing
the save effect to immediately fire with uninitialized default values
(theme: "", fontFamily: "", fontSize: 0), producing a 400 error.

- Add settingsDirty flag, set via setTimeout(0) after initial apply so
  the save effect is blocked for the first load and only runs on real
  user-driven changes
- Also accept empty string / 0 as 'not provided' in PUT /api/settings
  validation as a defensive backstop
2026-04-03 21:07:01 +05:00
Admin
24d73cb730 fix: add device_fingerprint to PB schema + fix homelab Redis routing
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 54s
Release / Docker / runner (push) Successful in 2m33s
Release / Docker / backend (push) Successful in 3m0s
Release / Docker / ui (push) Successful in 1m56s
Release / Gitea Release (push) Successful in 20s
OAuth login was silently failing: upsertUserSession() queried the
device_fingerprint column which didn't exist in the user_sessions
collection, PocketBase returned 400, the fallback authSessionId was
never written to the DB, and isSessionRevoked() immediately revoked
the cookie on first load after the OAuth redirect.

- scripts/pb-init-v3.sh: add device_fingerprint text field to the
  user_sessions create block (new installs) and add an idempotent
  add_field migration line (existing installs)

Audio jobs were stuck pending because the homelab runner was
connecting to its own local Redis instead of the prod VPS Redis.

- homelab/docker-compose.yml: change hardcoded REDIS_ADDR=redis:6379
  to ${REDIS_ADDR} so Doppler injects rediss://redis.libnovel.cc:6380
  (the Caddy TLS proxy that bridges the homelab runner to prod Redis)
2026-04-03 20:37:10 +05:00
Admin
19aeb90403 perf: cache home stats + ratings, fix discover card pop animation
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 48s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / runner (push) Successful in 2m48s
Release / Docker / backend (push) Successful in 2m52s
Release / Docker / ui (push) Successful in 2m4s
Release / Gitea Release (push) Successful in 21s
Cache home stats (10 min) and recently added books (5 min) to avoid
hitting PocketBase on every homepage load. Cache all ratings for
discovery ranking (5 min) with invalidation on setBookRating.
invalidateBooksCache now clears all related keys atomically.

Fix discover card pop-to-full-size bug: new card now transitions from
scale(0.95) to scale(1.0) matching its back-card position, instead of
snapping to full size instantly after each swipe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 20:15:58 +05:00
Admin
06d4a7bfd4 feat: profile stats, discover history, end-of-chapter sleep, rating-ranked deck
All checks were successful
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 52s
Release / Docker / backend (push) Successful in 2m32s
Release / Docker / ui (push) Successful in 2m15s
Release / Docker / runner (push) Successful in 2m50s
Release / Gitea Release (push) Successful in 22s
**Profile stats tab**
- New Stats tab on /profile page (Profile / Stats switcher)
- Reading overview: chapters read, completed, reading, plan-to-read counts
- Activity cards: day streak + avg rating given
- Favourite genres (top 3 by frequency across library/progress)
- getUserStats() in pocketbase.ts — computes streak, shelf counts, genre freq

**Discover history tab**
- New History tab on /discover with full voted-book list
- Per-entry: cover thumbnail, title link, author, action label (Liked/Skipped/etc.)
- Undo button: optimistic update + DELETE /api/discover/vote?slug=...
- Clear all history button; tab shows vote count badge
- getVotedBooks(), undoDiscoveryVote() in pocketbase.ts

**Rating-ranked discovery deck**
- getBooksForDiscovery now sorts by community avg rating before returning
- Tier-based shuffle: books within the same ±0.5 star bucket are still randomised
- Higher-rated books surface earlier without making the deck fully deterministic

**End-of-chapter sleep timer**
- New cycle option: Off → End of Chapter → 15m → 30m → 45m → 60m → Off
- sleepAfterChapter flag in AudioStore; layout handles it in onended (skips auto-next)
- Button shows "End Ch." label when active in this mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 07:26:54 +05:00
Admin
73a92ccf8f fix: deduplicate sessions with device fingerprint upsert
All checks were successful
Release / Test backend (push) Successful in 29s
Release / Check ui (push) Successful in 41s
Release / Docker / caddy (push) Successful in 50s
Release / Docker / ui (push) Successful in 2m8s
Release / Docker / backend (push) Successful in 3m17s
Release / Docker / runner (push) Successful in 3m13s
Release / Gitea Release (push) Successful in 12s
OAuth callbacks were creating a new session record on every login from
the same device because user-agent/IP were hardcoded as empty strings,
producing a pile-up of 6+ identical 'Unknown browser' sessions.

- Add upsertUserSession(): looks up existing session by user_id +
  device_fingerprint (SHA-256 of ua::ip, first 16 hex chars); reuses
  and touches it (returning the same authSessionId) if found, creates
  a new record otherwise
- Add device_fingerprint field to UserSession interface
- Fix OAuth callback to read real user-agent/IP from request headers
  (they are available in RequestHandler via request.headers)
- Switch both OAuth and password login to upsertUserSession so the
  returned authSessionId is used for the auth token
- Extend pruneStaleUserSessions to also cap sessions at 10 per user
- Keep createUserSession as a deprecated shim for gradual migration
2026-04-02 22:22:17 +05:00
106 changed files with 7893 additions and 368 deletions

View File

@@ -190,17 +190,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Fetch releases from Gitea API
run: |
set -euo pipefail
RESPONSE=$(curl -sfL \
-H "Accept: application/json" \
"http://gitea.kalekber.cc/api/v1/repos/kamil/libnovel/releases?limit=50&page=1")
# Validate JSON before writing — fails hard if response is not a JSON array
COUNT=$(echo "$RESPONSE" | jq 'if type == "array" then length else error("expected array, got \(type)") end')
echo "$RESPONSE" > ui/static/releases.json
echo "Fetched $COUNT releases"
- uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
@@ -278,8 +267,25 @@ jobs:
with:
fetch-depth: 0
- name: Extract release notes from tag commit
id: notes
run: |
set -euo pipefail
# Subject line (first line of commit message) → release title
SUBJECT=$(git log -1 --format="%s" "${{ gitea.sha }}")
# Body (everything after the blank line) → release body
BODY=$(git log -1 --format="%b" "${{ gitea.sha }}" | sed '/^Co-Authored-By:/d' | sed '/^[[:space:]]*$/{ N; /^\n$/d }' | sed 's/^[[:space:]]*$//' | awk 'NF || !p; {p = !NF}')
echo "title=${SUBJECT}" >> "$GITHUB_OUTPUT"
# Use a heredoc delimiter to safely handle multi-line body
{
echo "body<<RELEASE_BODY_EOF"
echo "${BODY}"
echo "RELEASE_BODY_EOF"
} >> "$GITHUB_OUTPUT"
- name: Create release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
token: ${{ secrets.GITEA_TOKEN }}
generate_release_notes: true
title: ${{ steps.notes.outputs.title }}
body: ${{ steps.notes.outputs.body }}

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

@@ -26,6 +26,7 @@ import (
"github.com/hibiken/asynq"
"github.com/libnovel/backend/internal/asynqqueue"
"github.com/libnovel/backend/internal/backend"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/config"
"github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/meili"
@@ -114,6 +115,33 @@ func run() error {
log.Info("POCKET_TTS_URL not set — pocket-tts voices unavailable in backend")
}
// ── Cloudflare Workers AI (voice sample generation + audio-stream live TTS) ──
var cfaiClient cfai.Client
if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
cfaiClient = cfai.New(cfg.CFAI.AccountID, cfg.CFAI.APIToken, cfg.CFAI.Model)
log.Info("cloudflare AI TTS enabled", "model", cfg.CFAI.Model)
} else {
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 != "" {
@@ -163,6 +191,10 @@ func run() error {
SearchIndex: searchIndex,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
ImageGen: imageGenClient,
TextGen: textGenClient,
BookWriter: store,
Log: log,
},
)

View File

@@ -23,6 +23,7 @@ import (
"github.com/getsentry/sentry-go"
"github.com/libnovel/backend/internal/asynqqueue"
"github.com/libnovel/backend/internal/browser"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/config"
"github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/libretranslate"
@@ -130,6 +131,15 @@ func run() error {
log.Warn("POCKET_TTS_URL not set — pocket-tts voice tasks will fail")
}
// ── Cloudflare Workers AI ────────────────────────────────────────────────
var cfaiClient cfai.Client
if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
cfaiClient = cfai.New(cfg.CFAI.AccountID, cfg.CFAI.APIToken, cfg.CFAI.Model)
log.Info("cloudflare AI TTS enabled", "model", cfg.CFAI.Model)
} else {
log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — CF AI voice tasks will fail")
}
// ── LibreTranslate ──────────────────────────────────────────────────────
ltClient := libretranslate.New(cfg.LibreTranslate.URL, cfg.LibreTranslate.APIKey)
if ltClient != nil {
@@ -191,6 +201,7 @@ func run() error {
Novel: novel,
Kokoro: kokoroClient,
PocketTTS: pocketTTSClient,
CFAI: cfaiClient,
LibreTranslate: ltClient,
Log: log,
}

View File

@@ -44,6 +44,7 @@ import (
"strings"
"time"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/meili"
@@ -774,7 +775,13 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
// Open the TTS stream (WAV or MP3 depending on format param).
var audioStream io.ReadCloser
if format == "wav" {
if pockettts.IsPocketTTSVoice(voice) {
if cfai.IsCFAIVoice(voice) {
if s.deps.CFAI == nil {
jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
return
}
audioStream, err = s.deps.CFAI.StreamAudioWAV(r.Context(), text, voice)
} else if pockettts.IsPocketTTSVoice(voice) {
if s.deps.PocketTTS == nil {
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
return
@@ -788,7 +795,13 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
audioStream, err = s.deps.Kokoro.StreamAudioWAV(r.Context(), text, voice)
}
} else {
if pockettts.IsPocketTTSVoice(voice) {
if cfai.IsCFAIVoice(voice) {
if s.deps.CFAI == nil {
jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
return
}
audioStream, err = s.deps.CFAI.StreamAudioMP3(r.Context(), text, voice)
} else if pockettts.IsPocketTTSVoice(voice) {
if s.deps.PocketTTS == nil {
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
return
@@ -1343,6 +1356,9 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
}
key := kokoro.VoiceSampleKey(voice)
if cfai.IsCFAIVoice(voice) {
key = cfai.VoiceSampleKey(voice)
}
// Generate sample on demand when it is not in MinIO yet.
if !s.deps.AudioStore.AudioExists(r.Context(), key) {
@@ -1352,7 +1368,13 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
mp3 []byte
err error
)
if pockettts.IsPocketTTSVoice(voice) {
if cfai.IsCFAIVoice(voice) {
if s.deps.CFAI == nil {
jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
return
}
mp3, err = s.deps.CFAI.GenerateAudio(r.Context(), voiceSampleText, voice)
} else if pockettts.IsPocketTTSVoice(voice) {
if s.deps.PocketTTS == nil {
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
return

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,424 @@
package backend
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
)
// 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"`
}
// textGenChapterNamesResponse is the JSON body returned by POST /api/admin/text-gen/chapter-names.
type textGenChapterNamesResponse struct {
// Chapters is the list of proposed chapter titles, indexed by chapter number.
Chapters []proposedChapterTitle `json:"chapters"`
// Model is the model that was used.
Model string `json:"model"`
// RawResponse is the raw model output for debugging / manual editing.
RawResponse string `json:"raw_response"`
}
// 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"`
}
// handleAdminTextGenChapterNames handles POST /api/admin/text-gen/chapter-names.
//
// Reads all chapter titles for the given slug, sends them to the LLM with the
// requested naming pattern, and returns proposed replacements. 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
}
// Build the prompt.
var chapterListSB strings.Builder
for _, ch := range chapters {
chapterListSB.WriteString(fmt.Sprintf("%d: %s\n", ch.Number, ch.Title))
}
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.`
userPrompt := fmt.Sprintf(
"Naming pattern: %s\n\nChapters:\n%s",
req.Pattern,
chapterListSB.String(),
)
model := cfai.TextModel(req.Model)
if model == "" {
model = cfai.DefaultTextModel
}
// Default to 4096 tokens so large chapter lists are not truncated.
maxTokens := req.MaxTokens
if maxTokens <= 0 {
maxTokens = 4096
}
s.deps.Log.Info("admin: text-gen chapter-names requested",
"slug", req.Slug, "chapters", len(chapters), "model", model, "max_tokens", maxTokens)
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 failed", "err", genErr)
jsonError(w, http.StatusBadGateway, "text generation failed: "+genErr.Error())
return
}
// Parse the JSON array from the model response.
proposed := parseChapterTitlesJSON(raw)
// Build the response: merge proposed titles with old titles.
// Index existing chapters by number for O(1) lookup.
existing := make(map[int]string, len(chapters))
for _, ch := range chapters {
existing[ch.Number] = ch.Title
}
result := make([]proposedChapterTitle, 0, len(proposed))
for _, p := range proposed {
result = append(result, proposedChapterTitle{
Number: p.Number,
OldTitle: existing[p.Number],
NewTitle: p.Title,
})
}
writeJSON(w, 0, textGenChapterNamesResponse{
Chapters: result,
Model: string(model),
RawResponse: raw,
})
}
// 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

@@ -30,6 +30,7 @@ import (
sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/meili"
@@ -69,6 +70,18 @@ type Dependencies struct {
// PocketTTS is the pocket-tts client (used for voice list only in the backend;
// audio generation is done by the runner).
PocketTTS pockettts.Client
// 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
}
@@ -179,6 +192,18 @@ 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)
// Voices list
mux.HandleFunc("GET /api/voices", s.handleVoices)
@@ -338,6 +363,23 @@ func (s *Server) voices(ctx context.Context) []domain.Voice {
}
}
// ── Cloudflare AI voices ──────────────────────────────────────────────────
if s.deps.CFAI != nil {
for _, speaker := range cfai.Speakers() {
gender := "m"
if cfai.IsFemale(speaker) {
gender = "f"
}
result = append(result, domain.Voice{
ID: cfai.VoiceID(speaker),
Engine: "cfai",
Lang: "en",
Gender: gender,
})
}
s.deps.Log.Info("backend: loaded CF AI voices", "count", len(cfai.Speakers()))
}
s.voiceMu.Lock()
s.cachedVoices = result
s.voiceMu.Unlock()

View File

@@ -0,0 +1,214 @@
// Package cfai provides a client for Cloudflare Workers AI Text-to-Speech models.
//
// The Cloudflare Workers AI REST API is used to run TTS models:
//
// POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model}
// Authorization: Bearer {apiToken}
// Content-Type: application/json
// { "text": "...", "speaker": "luna" }
//
// → 200 audio/mpeg — raw MP3 bytes
//
// Currently supported model: @cf/deepgram/aura-2-en (40 English speakers).
// Voice IDs are prefixed with "cfai:" to distinguish them from Kokoro/pocket-tts
// voices (e.g. "cfai:luna", "cfai:orion").
//
// The API is batch-only (no streaming), so GenerateAudio waits for the full
// 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.
package cfai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const (
// DefaultModel is the Cloudflare Workers AI TTS model used by default.
DefaultModel = "@cf/deepgram/aura-2-en"
// voicePrefix is the prefix used to namespace CF AI voice IDs.
voicePrefix = "cfai:"
)
// aura2Speakers is the exhaustive list of speakers supported by aura-2-en.
var aura2Speakers = []string{
"amalthea", "andromeda", "apollo", "arcas", "aries", "asteria",
"athena", "atlas", "aurora", "callista", "cora", "cordelia",
"delia", "draco", "electra", "harmonia", "helena", "hera",
"hermes", "hyperion", "iris", "janus", "juno", "jupiter",
"luna", "mars", "minerva", "neptune", "odysseus", "ophelia",
"orion", "orpheus", "pandora", "phoebe", "pluto", "saturn",
"thalia", "theia", "vesta", "zeus",
}
// femaleSpeakers is the set of aura-2-en speaker names that are female voices.
var femaleSpeakers = map[string]struct{}{
"amalthea": {}, "andromeda": {}, "aries": {}, "asteria": {},
"athena": {}, "aurora": {}, "callista": {}, "cora": {},
"cordelia": {}, "delia": {}, "electra": {}, "harmonia": {},
"helena": {}, "hera": {}, "iris": {}, "juno": {},
"luna": {}, "minerva": {}, "ophelia": {}, "pandora": {},
"phoebe": {}, "thalia": {}, "theia": {}, "vesta": {},
}
// IsCFAIVoice reports whether voice is served by the Cloudflare AI client.
// CF AI voices use the "cfai:" prefix, e.g. "cfai:luna".
func IsCFAIVoice(voice string) bool {
return strings.HasPrefix(voice, voicePrefix)
}
// SpeakerName strips the "cfai:" prefix and returns the bare speaker name.
// If voice is not a CF AI voice the original string is returned unchanged.
func SpeakerName(voice string) string {
return strings.TrimPrefix(voice, voicePrefix)
}
// VoiceID returns the full voice ID (with prefix) for a bare speaker name.
func VoiceID(speaker string) string {
return voicePrefix + speaker
}
// VoiceSampleKey returns the MinIO object key for a CF AI voice sample MP3.
func VoiceSampleKey(voice string) string {
safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
return r
}
return '_'
}, voice)
return fmt.Sprintf("_voice-samples/%s.mp3", safe)
}
// IsFemale reports whether the given CF AI voice ID (with or without prefix)
// is a female speaker.
func IsFemale(voice string) bool {
speaker := SpeakerName(voice)
_, ok := femaleSpeakers[speaker]
return ok
}
// Speakers returns all available bare speaker names for aura-2-en.
func Speakers() []string {
out := make([]string, len(aura2Speakers))
copy(out, aura2Speakers)
return out
}
// Client is the interface for interacting with Cloudflare Workers AI TTS.
type Client interface {
// GenerateAudio synthesises text using the given voice (e.g. "cfai:luna")
// and returns raw MP3 bytes.
GenerateAudio(ctx context.Context, text, voice string) ([]byte, error)
// StreamAudioMP3 is not natively supported by the CF AI batch API.
// It buffers the full response and returns an io.ReadCloser over the bytes,
// so callers can use it like a stream without special-casing.
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
// StreamAudioWAV is not natively supported; the CF AI model returns MP3.
// This method returns the same MP3 bytes wrapped as an io.ReadCloser.
StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error)
// ListVoices returns all available voice IDs (with the "cfai:" prefix).
ListVoices(ctx context.Context) ([]string, error)
}
// httpClient is the concrete CF AI HTTP client.
type httpClient struct {
accountID string
apiToken string
model string
http *http.Client
}
// New returns a Client for the given Cloudflare account and API token.
// model defaults to DefaultModel when empty.
func New(accountID, apiToken, model string) Client {
if model == "" {
model = DefaultModel
}
return &httpClient{
accountID: accountID,
apiToken: apiToken,
model: model,
http: &http.Client{Timeout: 5 * time.Minute},
}
}
// GenerateAudio calls the Cloudflare Workers AI TTS endpoint and returns MP3 bytes.
func (c *httpClient) GenerateAudio(ctx context.Context, text, voice string) ([]byte, error) {
if text == "" {
return nil, fmt.Errorf("cfai: empty text")
}
speaker := SpeakerName(voice)
if speaker == "" {
speaker = "luna"
}
body, err := json.Marshal(map[string]any{
"text": text,
"speaker": speaker,
})
if err != nil {
return nil, fmt.Errorf("cfai: marshal request: %w", err)
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s",
c.accountID, c.model)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("cfai: 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: request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("cfai: server returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
mp3, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cfai: read response: %w", err)
}
return mp3, nil
}
// 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)
if err != nil {
return nil, err
}
return io.NopCloser(bytes.NewReader(mp3)), nil
}
// StreamAudioWAV generates audio (MP3) and wraps it as an io.ReadCloser.
// Note: the CF AI aura-2-en model returns MP3 regardless of the method name.
func (c *httpClient) StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error) {
return c.StreamAudioMP3(ctx, text, voice)
}
// ListVoices returns all available CF AI voice IDs (with the "cfai:" prefix).
func (c *httpClient) ListVoices(_ context.Context) ([]string, error) {
ids := make([]string, len(aura2Speakers))
for i, s := range aura2Speakers {
ids[i] = VoiceID(s)
}
return ids, nil
}

View File

@@ -0,0 +1,312 @@
// 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/jpeg" // register JPEG decoder
_ "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)
}
// 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)
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
}
// 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,239 @@
// 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 }
var wrapper struct {
Result struct {
Response string `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)
}
return wrapper.Result.Response, nil
}
// Models returns all supported text generation model metadata.
func (c *textGenHTTPClient) Models() []TextModelInfo {
return AllTextModels()
}

View File

@@ -66,6 +66,18 @@ type PocketTTS struct {
URL string
}
// CFAI holds credentials for Cloudflare Workers AI TTS.
type CFAI struct {
// AccountID is the Cloudflare account ID.
// An empty string disables CF AI generation.
AccountID string
// APIToken is a Workers AI API token with Workers AI Read+Edit permissions.
APIToken string
// Model is the Workers AI TTS model ID.
// Defaults to "@cf/deepgram/aura-2-en" when empty.
Model string
}
// LibreTranslate holds connection settings for a self-hosted LibreTranslate instance.
type LibreTranslate struct {
// URL is the base URL of the LibreTranslate instance, e.g. https://translate.libnovel.cc
@@ -153,6 +165,7 @@ type Config struct {
MinIO MinIO
Kokoro Kokoro
PocketTTS PocketTTS
CFAI CFAI
LibreTranslate LibreTranslate
HTTP HTTP
Runner Runner
@@ -203,6 +216,12 @@ func Load() Config {
URL: envOr("POCKET_TTS_URL", ""),
},
CFAI: CFAI{
AccountID: envOr("CFAI_ACCOUNT_ID", ""),
APIToken: envOr("CFAI_API_TOKEN", ""),
Model: envOr("CFAI_TTS_MODEL", ""),
},
LibreTranslate: LibreTranslate{
URL: envOr("LIBRETRANSLATE_URL", ""),
APIKey: envOr("LIBRETRANSLATE_API_KEY", ""),

View File

@@ -27,6 +27,7 @@ import (
"go.opentelemetry.io/otel/codes"
"github.com/libnovel/backend/internal/bookstore"
"github.com/libnovel/backend/internal/cfai"
"github.com/libnovel/backend/internal/domain"
"github.com/libnovel/backend/internal/kokoro"
"github.com/libnovel/backend/internal/libretranslate"
@@ -112,6 +113,9 @@ type Dependencies struct {
// PocketTTS is the pocket-tts client (CPU, kyutai voices: alba, marius, etc.).
// If nil, pocket-tts voice tasks will fail with a clear error.
PocketTTS pockettts.Client
// CFAI is the Cloudflare Workers AI TTS client (cfai:* prefixed voices).
// If nil, CF AI voice tasks will fail with a clear error.
CFAI cfai.Client
// LibreTranslate is the machine translation client.
// If nil, translation tasks will fail with a clear error.
LibreTranslate libretranslate.Client
@@ -555,6 +559,18 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
return
}
log.Info("runner: audio generated via pocket-tts", "voice", task.Voice)
} else if cfai.IsCFAIVoice(task.Voice) {
if r.deps.CFAI == nil {
fail("cloudflare AI client not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN empty)")
return
}
var genErr error
audioData, genErr = r.deps.CFAI.GenerateAudio(ctx, text, task.Voice)
if genErr != nil {
fail(fmt.Sprintf("cfai generate: %v", genErr))
return
}
log.Info("runner: audio generated via cloudflare AI", "voice", task.Voice)
} else {
if r.deps.Kokoro == nil {
fail("kokoro client not configured (KOKORO_URL is empty)")

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

View File

@@ -63,7 +63,7 @@ services:
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
# ── Asynq / Redis ─────────────────────────────────────────────────────
REDIS_ADDR: "redis:6379"
REDIS_ADDR: "${REDIS_ADDR}"
REDIS_PASSWORD: "${REDIS_PASSWORD}"
KOKORO_URL: "http://kokoro-fastapi:8880"

View File

@@ -13,6 +13,11 @@
# - RUNNER_SKIP_INITIAL_CATALOGUE_REFRESH=true
# - REDIS_ADDR → rediss://redis.libnovel.cc:6380 (prod Redis via Caddy TLS proxy)
# - LibreTranslate service for machine translation (internal network only)
#
# extra_hosts pins storage.libnovel.cc and pb.libnovel.cc to the prod server IP
# (165.22.70.138) so that large PutObject uploads and PocketBase writes bypass
# Cloudflare's 100-second proxy timeout entirely. TLS still terminates at Caddy
# on prod; the TLS certificate is valid for the domain names so SNI works fine.
services:
libretranslate:
@@ -35,6 +40,12 @@ services:
stop_grace_period: 135s
depends_on:
- libretranslate
# Pin prod subdomains to the prod server IP to bypass Cloudflare's 100s
# proxy timeout. Large MP3 PutObject uploads and PocketBase writes go
# directly to Caddy on prod; TLS and SNI still work normally.
extra_hosts:
- "storage.libnovel.cc:165.22.70.138"
- "pb.libnovel.cc:165.22.70.138"
environment:
# ── PocketBase ──────────────────────────────────────────────────────────
POCKETBASE_URL: "https://pb.libnovel.cc"
@@ -63,6 +74,10 @@ services:
# ── Pocket TTS ──────────────────────────────────────────────────────────
POCKET_TTS_URL: "${POCKET_TTS_URL}"
# ── Cloudflare Workers AI TTS ────────────────────────────────────────────
CFAI_ACCOUNT_ID: "${CFAI_ACCOUNT_ID}"
CFAI_API_TOKEN: "${CFAI_API_TOKEN}"
# ── LibreTranslate (internal Docker network) ────────────────────────────
LIBRETRANSLATE_URL: "http://libretranslate:5000"
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"

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

@@ -190,14 +190,15 @@ create "app_users" '{
{"name":"oauth_id", "type":"text"}
]}'
create "user_sessions" '{
create "user_sessions" '{
"name":"user_sessions","type":"base","fields":[
{"name":"user_id", "type":"text","required":true},
{"name":"session_id","type":"text","required":true},
{"name":"user_agent","type":"text"},
{"name":"ip", "type":"text"},
{"name":"created_at","type":"text"},
{"name":"last_seen", "type":"text"}
{"name":"user_id", "type":"text","required":true},
{"name":"session_id", "type":"text","required":true},
{"name":"user_agent", "type":"text"},
{"name":"ip", "type":"text"},
{"name":"device_fingerprint", "type":"text"},
{"name":"created_at", "type":"text"},
{"name":"last_seen", "type":"text"}
]}'
create "user_library" '{
@@ -291,5 +292,6 @@ add_field "app_users" "oauth_id" "text"
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"
log "done"

3
ui/.gitignore vendored
View File

@@ -22,5 +22,4 @@ Thumbs.db
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Generated by CI at build time — do not commit
/static/releases.json

View File

@@ -3,6 +3,7 @@
"nav_library": "Library",
"nav_catalogue": "Catalogue",
"nav_feed": "Feed",
"nav_feedback": "Feedback",
"nav_admin": "Admin",
"nav_profile": "Profile",
@@ -301,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",
@@ -361,12 +380,15 @@
"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_feedback": "Feedback",
"admin_nav_errors": "Errors",
"admin_nav_analytics": "Analytics",
"admin_nav_logs": "Logs",
"admin_nav_uptime": "Uptime",
"admin_nav_push": "Push",
"admin_nav_gitea": "Gitea",
"admin_scrape_status_idle": "Idle",
"admin_scrape_status_running": "Running",
@@ -419,5 +441,16 @@
"profile_text_size_sm": "Small",
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Large",
"profile_text_size_xl": "X-Large"
"profile_text_size_xl": "X-Large",
"feed_page_title": "Feed — LibNovel",
"feed_heading": "Following Feed",
"feed_subheading": "Books your followed users are reading",
"feed_empty_heading": "Nothing here yet",
"feed_empty_body": "Follow other readers to see what they're reading.",
"feed_not_logged_in": "Sign in to see your feed.",
"feed_reader_label": "reading",
"feed_chapters_label": "{n} chapters",
"feed_browse_cta": "Browse catalogue",
"feed_find_users_cta": "Discover readers"
}

View File

@@ -3,6 +3,7 @@
"nav_library": "Bibliothèque",
"nav_catalogue": "Catalogue",
"nav_feed": "Fil",
"nav_feedback": "Retour",
"nav_admin": "Admin",
"nav_profile": "Profil",
@@ -301,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",
@@ -361,6 +380,8 @@
"admin_nav_audio": "Audio",
"admin_nav_translation": "Traduction",
"admin_nav_changelog": "Modifications",
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_feedback": "Retours",
"admin_nav_errors": "Erreurs",
"admin_nav_analytics": "Analytique",
@@ -418,5 +439,17 @@
"profile_text_size_sm": "Petit",
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Grand",
"profile_text_size_xl": "Très grand"
"profile_text_size_xl": "Très grand",
"feed_page_title": "Fil — LibNovel",
"feed_heading": "Fil d'abonnements",
"feed_subheading": "Livres lus par vos abonnements",
"feed_empty_heading": "Rien encore",
"feed_empty_body": "Suivez d'autres lecteurs pour voir ce qu'ils lisent.",
"feed_not_logged_in": "Connectez-vous pour voir votre fil.",
"feed_reader_label": "lit",
"feed_chapters_label": "{n} chapitres",
"feed_browse_cta": "Parcourir le catalogue",
"feed_find_users_cta": "Trouver des lecteurs",
"admin_nav_gitea": "Gitea"
}

View File

@@ -3,6 +3,7 @@
"nav_library": "Perpustakaan",
"nav_catalogue": "Katalog",
"nav_feed": "Umpan",
"nav_feedback": "Masukan",
"nav_admin": "Admin",
"nav_profile": "Profil",
@@ -301,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",
@@ -361,6 +380,8 @@
"admin_nav_audio": "Audio",
"admin_nav_translation": "Terjemahan",
"admin_nav_changelog": "Perubahan",
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_feedback": "Masukan",
"admin_nav_errors": "Kesalahan",
"admin_nav_analytics": "Analitik",
@@ -418,5 +439,17 @@
"profile_text_size_sm": "Kecil",
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Besar",
"profile_text_size_xl": "Sangat Besar"
"profile_text_size_xl": "Sangat Besar",
"feed_page_title": "Umpan — LibNovel",
"feed_heading": "Umpan Ikutan",
"feed_subheading": "Buku yang sedang dibaca oleh pengguna yang Anda ikuti",
"feed_empty_heading": "Belum ada apa-apa",
"feed_empty_body": "Ikuti pembaca lain untuk melihat apa yang mereka baca.",
"feed_not_logged_in": "Masuk untuk melihat umpan Anda.",
"feed_reader_label": "membaca",
"feed_chapters_label": "{n} bab",
"feed_browse_cta": "Jelajahi katalog",
"feed_find_users_cta": "Temukan pembaca",
"admin_nav_gitea": "Gitea"
}

View File

@@ -3,6 +3,7 @@
"nav_library": "Biblioteca",
"nav_catalogue": "Catálogo",
"nav_feed": "Feed",
"nav_feedback": "Feedback",
"nav_admin": "Admin",
"nav_profile": "Perfil",
@@ -301,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",
@@ -361,6 +380,8 @@
"admin_nav_audio": "Áudio",
"admin_nav_translation": "Tradução",
"admin_nav_changelog": "Alterações",
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_feedback": "Feedback",
"admin_nav_errors": "Erros",
"admin_nav_analytics": "Análise",
@@ -418,5 +439,17 @@
"profile_text_size_sm": "Pequeno",
"profile_text_size_md": "Normal",
"profile_text_size_lg": "Grande",
"profile_text_size_xl": "Muito grande"
"profile_text_size_xl": "Muito grande",
"feed_page_title": "Feed — LibNovel",
"feed_heading": "Feed de seguidos",
"feed_subheading": "Livros que seus seguidos estão lendo",
"feed_empty_heading": "Nada aqui ainda",
"feed_empty_body": "Siga outros leitores para ver o que estão lendo.",
"feed_not_logged_in": "Faça login para ver seu feed.",
"feed_reader_label": "lendo",
"feed_chapters_label": "{n} capítulos",
"feed_browse_cta": "Ver catálogo",
"feed_find_users_cta": "Encontrar leitores",
"admin_nav_gitea": "Gitea"
}

View File

@@ -3,6 +3,7 @@
"nav_library": "Библиотека",
"nav_catalogue": "Каталог",
"nav_feed": "Лента",
"nav_feedback": "Обратная связь",
"nav_admin": "Админ",
"nav_profile": "Профиль",
@@ -301,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": "Перепарсить книгу",
@@ -361,6 +380,8 @@
"admin_nav_audio": "Аудио",
"admin_nav_translation": "Перевод",
"admin_nav_changelog": "Изменения",
"admin_nav_image_gen": "Image Gen",
"admin_nav_text_gen": "Text Gen",
"admin_nav_feedback": "Отзывы",
"admin_nav_errors": "Ошибки",
"admin_nav_analytics": "Аналитика",
@@ -418,5 +439,17 @@
"profile_text_size_sm": "Маленький",
"profile_text_size_md": "Нормальный",
"profile_text_size_lg": "Большой",
"profile_text_size_xl": "Очень большой"
"profile_text_size_xl": "Очень большой",
"feed_page_title": "Лента — LibNovel",
"feed_heading": "Лента подписок",
"feed_subheading": "Книги, которые читают ваши подписки",
"feed_empty_heading": "Пока ничего нет",
"feed_empty_body": "Подпишитесь на других читателей, чтобы видеть, что они читают.",
"feed_not_logged_in": "Войдите, чтобы видеть свою ленту.",
"feed_reader_label": "читает",
"feed_chapters_label": "{n} глав",
"feed_browse_cta": "Каталог",
"feed_find_users_cta": "Найти читателей",
"admin_nav_gitea": "Gitea"
}

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

@@ -79,6 +79,9 @@ class AudioStore {
/** Epoch ms when sleep timer should fire. 0 = off. */
sleepUntil = $state(0);
/** When true, pause after the current chapter ends instead of navigating. */
sleepAfterChapter = $state(false);
// ── Auto-next ────────────────────────────────────────────────────────────
/**
* When true, navigates to the next chapter when the current one ends

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,12 +82,14 @@
nextChapter = null,
chapters = [],
voices = [],
onProRequired = undefined
onProRequired = undefined,
playerStyle = 'standard'
}: Props = $props();
// ── Derived: voices grouped by engine ──────────────────────────────────
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
// ── Voice selector state ────────────────────────────────────────────────
let showVoicePanel = $state(false);
@@ -98,6 +102,7 @@
* Human-readable label for a voice.
* Kokoro: "af_bella" → "Bella (US F)"
* Pocket-TTS: "alba" → "Alba (EN F)"
* CF AI: "cfai:luna" → "Luna (EN F)"
* Falls back gracefully if called with a bare string (e.g. from the store default).
*/
function voiceLabel(v: Voice | string): string {
@@ -110,6 +115,14 @@
return kokoroLabelFromId(v);
}
if (v.engine === 'cfai') {
// "cfai:luna" → "Luna (EN F)"
const speaker = v.id.startsWith('cfai:') ? v.id.slice(5) : v.id;
const name = speaker.replace(/\b\w/g, (c) => c.toUpperCase());
const genderLabel = v.gender.toUpperCase();
return `${name} (EN ${genderLabel})`;
}
if (v.engine === 'pocket-tts') {
const langLabel = v.lang.toUpperCase().replace('-', '');
const genderLabel = v.gender.toUpperCase();
@@ -554,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();
@@ -699,16 +740,22 @@
});
function cycleSleepTimer() {
if (!audioStore.sleepUntil) {
// Start at first option (15 min)
// Currently: no timer active at all
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = true;
return;
}
// Currently: end-of-chapter mode — move to 15m
if (audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = false;
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[0] * 60 * 1000;
return;
}
// Currently: timed mode — cycle to next or turn off
const remaining = audioStore.sleepUntil - Date.now();
const currentMin = Math.round(remaining / 60000);
const idx = SLEEP_OPTIONS.findIndex(m => m >= currentMin);
const idx = SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
if (idx === -1 || idx === SLEEP_OPTIONS.length - 1) {
// Was at max or past last — turn off
audioStore.sleepUntil = 0;
} else {
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[idx + 1] * 60 * 1000;
@@ -722,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} />
@@ -772,6 +837,121 @@
</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
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">
@@ -838,6 +1018,16 @@
{@render voiceRow(v)}
{/each}
{/if}
<!-- Cloudflare AI section -->
{#if cfaiVoices.length > 0}
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 || pocketVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Cloudflare AI</span>
</div>
{#each cfaiVoices as v (v.id)}
{@render voiceRow(v)}
{/each}
{/if}
</div>
<div class="px-3 py-2 border-t border-(--color-border) bg-(--color-surface-2)/50">
<p class="text-xs text-(--color-muted)">
@@ -933,14 +1123,20 @@
<Button
variant="ghost"
size="sm"
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
onclick={cycleSleepTimer}
title={audioStore.sleepUntil ? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining` : 'Sleep timer off'}
title={audioStore.sleepAfterChapter
? 'Stop after this chapter'
: audioStore.sleepUntil
? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining`
: 'Sleep timer off'}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
{#if audioStore.sleepUntil}
{#if audioStore.sleepAfterChapter}
End Ch.
{:else if audioStore.sleepUntil}
{formatSleepRemaining(sleepRemainingSec)}
{:else}
Sleep
@@ -994,3 +1190,4 @@
</Button>
{/if}
</div>
{/if}

View File

@@ -2,6 +2,7 @@
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
export * from './nav_library.js'
export * from './nav_catalogue.js'
export * from './nav_feed.js'
export * from './nav_feedback.js'
export * from './nav_admin.js'
export * from './nav_profile.js'
@@ -278,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'
@@ -332,12 +351,15 @@ 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_feedback.js'
export * from './admin_nav_errors.js'
export * from './admin_nav_analytics.js'
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_scrape_status_idle.js'
export * from './admin_scrape_full_catalogue.js'
export * from './admin_scrape_single_book.js'
@@ -383,4 +405,14 @@ export * from './profile_text_size.js'
export * from './profile_text_size_sm.js'
export * from './profile_text_size_md.js'
export * from './profile_text_size_lg.js'
export * from './profile_text_size_xl.js'
export * from './profile_text_size_xl.js'
export * from './feed_page_title.js'
export * from './feed_heading.js'
export * from './feed_subheading.js'
export * from './feed_empty_heading.js'
export * from './feed_empty_body.js'
export * from './feed_not_logged_in.js'
export * from './feed_reader_label.js'
export * from './feed_chapters_label.js'
export * from './feed_browse_cta.js'
export * from './feed_find_users_cta.js'

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_AnalyticsInputs */
const en_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Analytics`);
const ru_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Аналитика`);
const id_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Analitik`);
const pt_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Análise`);
const fr_admin_nav_analytics = (_inputs = {}) => /** @type {LocalizedString} */ (`Analytique`);
const en_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Analytics`)
};
const ru_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Аналитика`)
};
const id_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Analitik`)
};
const pt_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Análise`)
};
const fr_admin_nav_analytics = /** @type {(inputs: Admin_Nav_AnalyticsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Analytique`)
};
/**
* | output |
* | --- |
* | "Analytics" |
*
* @param {Admin_Nav_AnalyticsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_analytics = /** @type {((inputs?: Admin_Nav_AnalyticsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_AnalyticsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_analytics(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_analytics = /** @type {((inputs?: Admin_Nav_AnalyticsInpu
if (locale === "id") return id_admin_nav_analytics(inputs)
if (locale === "pt") return pt_admin_nav_analytics(inputs)
return fr_admin_nav_analytics(inputs)
});
});

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_AudioInputs */
const en_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Audio`);
const ru_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Аудио`);
const id_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Audio`);
const pt_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Áudio`);
const fr_admin_nav_audio = (_inputs = {}) => /** @type {LocalizedString} */ (`Audio`);
const en_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio`)
};
const ru_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Аудио`)
};
const id_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio`)
};
const pt_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Áudio`)
};
const fr_admin_nav_audio = /** @type {(inputs: Admin_Nav_AudioInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Audio`)
};
/**
* | output |
* | --- |
* | "Audio" |
*
* @param {Admin_Nav_AudioInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_audio = /** @type {((inputs?: Admin_Nav_AudioInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_AudioInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_audio(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_audio = /** @type {((inputs?: Admin_Nav_AudioInputs, opti
if (locale === "id") return id_admin_nav_audio(inputs)
if (locale === "pt") return pt_admin_nav_audio(inputs)
return fr_admin_nav_audio(inputs)
});
});

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_ChangelogInputs */
const en_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Changelog`);
const ru_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Изменения`);
const id_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Perubahan`);
const pt_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Alterações`);
const fr_admin_nav_changelog = (_inputs = {}) => /** @type {LocalizedString} */ (`Modifications`);
const en_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Changelog`)
};
const ru_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Изменения`)
};
const id_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Perubahan`)
};
const pt_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Alterações`)
};
const fr_admin_nav_changelog = /** @type {(inputs: Admin_Nav_ChangelogInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Modifications`)
};
/**
* | output |
* | --- |
* | "Changelog" |
*
* @param {Admin_Nav_ChangelogInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_changelog = /** @type {((inputs?: Admin_Nav_ChangelogInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ChangelogInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_changelog(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_changelog = /** @type {((inputs?: Admin_Nav_ChangelogInpu
if (locale === "id") return id_admin_nav_changelog(inputs)
if (locale === "pt") return pt_admin_nav_changelog(inputs)
return fr_admin_nav_changelog(inputs)
});
});

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_ErrorsInputs */
const en_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Errors`);
const ru_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Ошибки`);
const id_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Kesalahan`);
const pt_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Erros`);
const fr_admin_nav_errors = (_inputs = {}) => /** @type {LocalizedString} */ (`Erreurs`);
const en_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Errors`)
};
const ru_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Ошибки`)
};
const id_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Kesalahan`)
};
const pt_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Erros`)
};
const fr_admin_nav_errors = /** @type {(inputs: Admin_Nav_ErrorsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Erreurs`)
};
/**
* | output |
* | --- |
* | "Errors" |
*
* @param {Admin_Nav_ErrorsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_errors = /** @type {((inputs?: Admin_Nav_ErrorsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ErrorsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_errors(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_errors = /** @type {((inputs?: Admin_Nav_ErrorsInputs, op
if (locale === "id") return id_admin_nav_errors(inputs)
if (locale === "pt") return pt_admin_nav_errors(inputs)
return fr_admin_nav_errors(inputs)
});
});

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_FeedbackInputs */
const en_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Feedback`);
const ru_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Отзывы`);
const id_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Masukan`);
const pt_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Feedback`);
const fr_admin_nav_feedback = (_inputs = {}) => /** @type {LocalizedString} */ (`Retours`);
const en_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Feedback`)
};
const ru_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Отзывы`)
};
const id_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Masukan`)
};
const pt_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Feedback`)
};
const fr_admin_nav_feedback = /** @type {(inputs: Admin_Nav_FeedbackInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Retours`)
};
/**
* | output |
* | --- |
* | "Feedback" |
*
* @param {Admin_Nav_FeedbackInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_feedback = /** @type {((inputs?: Admin_Nav_FeedbackInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_FeedbackInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_feedback(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_feedback = /** @type {((inputs?: Admin_Nav_FeedbackInputs
if (locale === "id") return id_admin_nav_feedback(inputs)
if (locale === "pt") return pt_admin_nav_feedback(inputs)
return fr_admin_nav_feedback(inputs)
});
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Admin_Nav_GiteaInputs */
const en_admin_nav_gitea = /** @type {(inputs: Admin_Nav_GiteaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gitea`)
};
const ru_admin_nav_gitea = /** @type {(inputs: Admin_Nav_GiteaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gitea`)
};
const id_admin_nav_gitea = /** @type {(inputs: Admin_Nav_GiteaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gitea`)
};
const pt_admin_nav_gitea = /** @type {(inputs: Admin_Nav_GiteaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gitea`)
};
const fr_admin_nav_gitea = /** @type {(inputs: Admin_Nav_GiteaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Gitea`)
};
/**
* | output |
* | --- |
* | "Gitea" |
*
* @param {Admin_Nav_GiteaInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_gitea = /** @type {((inputs?: Admin_Nav_GiteaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_GiteaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_gitea(inputs)
if (locale === "ru") return ru_admin_nav_gitea(inputs)
if (locale === "id") return id_admin_nav_gitea(inputs)
if (locale === "pt") return pt_admin_nav_gitea(inputs)
return fr_admin_nav_gitea(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

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_LogsInputs */
const en_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Logs`);
const ru_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Логи`);
const id_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Log`);
const pt_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Logs`);
const fr_admin_nav_logs = (_inputs = {}) => /** @type {LocalizedString} */ (`Journaux`);
const en_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Logs`)
};
const ru_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Логи`)
};
const id_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Log`)
};
const pt_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Logs`)
};
const fr_admin_nav_logs = /** @type {(inputs: Admin_Nav_LogsInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Journaux`)
};
/**
* | output |
* | --- |
* | "Logs" |
*
* @param {Admin_Nav_LogsInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_logs = /** @type {((inputs?: Admin_Nav_LogsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_LogsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_logs(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_logs = /** @type {((inputs?: Admin_Nav_LogsInputs, option
if (locale === "id") return id_admin_nav_logs(inputs)
if (locale === "pt") return pt_admin_nav_logs(inputs)
return fr_admin_nav_logs(inputs)
});
});

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_PushInputs */
const en_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Push`);
const ru_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Уведомления`);
const id_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Notifikasi`);
const pt_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Notificações`);
const fr_admin_nav_push = (_inputs = {}) => /** @type {LocalizedString} */ (`Notifications`);
const en_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Push`)
};
const ru_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Уведомления`)
};
const id_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Notifikasi`)
};
const pt_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Notificações`)
};
const fr_admin_nav_push = /** @type {(inputs: Admin_Nav_PushInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Notifications`)
};
/**
* | output |
* | --- |
* | "Push" |
*
* @param {Admin_Nav_PushInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_push = /** @type {((inputs?: Admin_Nav_PushInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_PushInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_push(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_push = /** @type {((inputs?: Admin_Nav_PushInputs, option
if (locale === "id") return id_admin_nav_push(inputs)
if (locale === "pt") return pt_admin_nav_push(inputs)
return fr_admin_nav_push(inputs)
});
});

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_ScrapeInputs */
const en_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Scrape`);
const ru_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Скрейпинг`);
const id_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Scrape`);
const pt_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Scrape`);
const fr_admin_nav_scrape = (_inputs = {}) => /** @type {LocalizedString} */ (`Scrape`);
const en_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Scrape`)
};
const ru_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Скрейпинг`)
};
const id_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Scrape`)
};
const pt_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Scrape`)
};
const fr_admin_nav_scrape = /** @type {(inputs: Admin_Nav_ScrapeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Scrape`)
};
/**
* | output |
* | --- |
* | "Scrape" |
*
* @param {Admin_Nav_ScrapeInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_scrape = /** @type {((inputs?: Admin_Nav_ScrapeInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ScrapeInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_scrape(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_scrape = /** @type {((inputs?: Admin_Nav_ScrapeInputs, op
if (locale === "id") return id_admin_nav_scrape(inputs)
if (locale === "pt") return pt_admin_nav_scrape(inputs)
return fr_admin_nav_scrape(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

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_TranslationInputs */
const en_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Translation`);
const ru_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Перевод`);
const id_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Terjemahan`);
const pt_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Tradução`);
const fr_admin_nav_translation = (_inputs = {}) => /** @type {LocalizedString} */ (`Traduction`);
const en_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Translation`)
};
const ru_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Перевод`)
};
const id_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Terjemahan`)
};
const pt_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Tradução`)
};
const fr_admin_nav_translation = /** @type {(inputs: Admin_Nav_TranslationInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Traduction`)
};
/**
* | output |
* | --- |
* | "Translation" |
*
* @param {Admin_Nav_TranslationInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_translation = /** @type {((inputs?: Admin_Nav_TranslationInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_TranslationInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_translation(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_translation = /** @type {((inputs?: Admin_Nav_Translation
if (locale === "id") return id_admin_nav_translation(inputs)
if (locale === "pt") return pt_admin_nav_translation(inputs)
return fr_admin_nav_translation(inputs)
});
});

View File

@@ -5,12 +5,35 @@ import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {{}} Admin_Nav_UptimeInputs */
const en_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Uptime`);
const ru_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Мониторинг`);
const id_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Uptime`);
const pt_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Uptime`);
const fr_admin_nav_uptime = (_inputs = {}) => /** @type {LocalizedString} */ (`Disponibilité`);
const en_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Uptime`)
};
const ru_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Мониторинг`)
};
const id_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Uptime`)
};
const pt_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Uptime`)
};
const fr_admin_nav_uptime = /** @type {(inputs: Admin_Nav_UptimeInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Disponibilité`)
};
/**
* | output |
* | --- |
* | "Uptime" |
*
* @param {Admin_Nav_UptimeInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const admin_nav_uptime = /** @type {((inputs?: Admin_Nav_UptimeInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_UptimeInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_admin_nav_uptime(inputs)
@@ -18,4 +41,4 @@ export const admin_nav_uptime = /** @type {((inputs?: Admin_Nav_UptimeInputs, op
if (locale === "id") return id_admin_nav_uptime(inputs)
if (locale === "pt") return pt_admin_nav_uptime(inputs)
return fr_admin_nav_uptime(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 {{}} Feed_Browse_CtaInputs */
const en_feed_browse_cta = /** @type {(inputs: Feed_Browse_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Browse catalogue`)
};
const ru_feed_browse_cta = /** @type {(inputs: Feed_Browse_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Каталог`)
};
const id_feed_browse_cta = /** @type {(inputs: Feed_Browse_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Jelajahi katalog`)
};
const pt_feed_browse_cta = /** @type {(inputs: Feed_Browse_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Ver catálogo`)
};
const fr_feed_browse_cta = /** @type {(inputs: Feed_Browse_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Parcourir le catalogue`)
};
/**
* | output |
* | --- |
* | "Browse catalogue" |
*
* @param {Feed_Browse_CtaInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_browse_cta = /** @type {((inputs?: Feed_Browse_CtaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Browse_CtaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_browse_cta(inputs)
if (locale === "ru") return ru_feed_browse_cta(inputs)
if (locale === "id") return id_feed_browse_cta(inputs)
if (locale === "pt") return pt_feed_browse_cta(inputs)
return fr_feed_browse_cta(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{ n: NonNullable<unknown> }} Feed_Chapters_LabelInputs */
const en_feed_chapters_label = /** @type {(inputs: Feed_Chapters_LabelInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`${i?.n} chapters`)
};
const ru_feed_chapters_label = /** @type {(inputs: Feed_Chapters_LabelInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`${i?.n} глав`)
};
const id_feed_chapters_label = /** @type {(inputs: Feed_Chapters_LabelInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`${i?.n} bab`)
};
const pt_feed_chapters_label = /** @type {(inputs: Feed_Chapters_LabelInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`${i?.n} capítulos`)
};
const fr_feed_chapters_label = /** @type {(inputs: Feed_Chapters_LabelInputs) => LocalizedString} */ (i) => {
return /** @type {LocalizedString} */ (`${i?.n} chapitres`)
};
/**
* | output |
* | --- |
* | "{n} chapters" |
*
* @param {Feed_Chapters_LabelInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_chapters_label = /** @type {((inputs: Feed_Chapters_LabelInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Chapters_LabelInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_chapters_label(inputs)
if (locale === "ru") return ru_feed_chapters_label(inputs)
if (locale === "id") return id_feed_chapters_label(inputs)
if (locale === "pt") return pt_feed_chapters_label(inputs)
return fr_feed_chapters_label(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_Empty_BodyInputs */
const en_feed_empty_body = /** @type {(inputs: Feed_Empty_BodyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Follow other readers to see what they're reading.`)
};
const ru_feed_empty_body = /** @type {(inputs: Feed_Empty_BodyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Подпишитесь на других читателей, чтобы видеть, что они читают.`)
};
const id_feed_empty_body = /** @type {(inputs: Feed_Empty_BodyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Ikuti pembaca lain untuk melihat apa yang mereka baca.`)
};
const pt_feed_empty_body = /** @type {(inputs: Feed_Empty_BodyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Siga outros leitores para ver o que estão lendo.`)
};
const fr_feed_empty_body = /** @type {(inputs: Feed_Empty_BodyInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Suivez d'autres lecteurs pour voir ce qu'ils lisent.`)
};
/**
* | output |
* | --- |
* | "Follow other readers to see what they're reading." |
*
* @param {Feed_Empty_BodyInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_empty_body = /** @type {((inputs?: Feed_Empty_BodyInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Empty_BodyInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_empty_body(inputs)
if (locale === "ru") return ru_feed_empty_body(inputs)
if (locale === "id") return id_feed_empty_body(inputs)
if (locale === "pt") return pt_feed_empty_body(inputs)
return fr_feed_empty_body(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_Empty_HeadingInputs */
const en_feed_empty_heading = /** @type {(inputs: Feed_Empty_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Nothing here yet`)
};
const ru_feed_empty_heading = /** @type {(inputs: Feed_Empty_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Пока ничего нет`)
};
const id_feed_empty_heading = /** @type {(inputs: Feed_Empty_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Belum ada apa-apa`)
};
const pt_feed_empty_heading = /** @type {(inputs: Feed_Empty_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Nada aqui ainda`)
};
const fr_feed_empty_heading = /** @type {(inputs: Feed_Empty_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Rien encore`)
};
/**
* | output |
* | --- |
* | "Nothing here yet" |
*
* @param {Feed_Empty_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_empty_heading = /** @type {((inputs?: Feed_Empty_HeadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Empty_HeadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_empty_heading(inputs)
if (locale === "ru") return ru_feed_empty_heading(inputs)
if (locale === "id") return id_feed_empty_heading(inputs)
if (locale === "pt") return pt_feed_empty_heading(inputs)
return fr_feed_empty_heading(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_Find_Users_CtaInputs */
const en_feed_find_users_cta = /** @type {(inputs: Feed_Find_Users_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Discover readers`)
};
const ru_feed_find_users_cta = /** @type {(inputs: Feed_Find_Users_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Найти читателей`)
};
const id_feed_find_users_cta = /** @type {(inputs: Feed_Find_Users_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Temukan pembaca`)
};
const pt_feed_find_users_cta = /** @type {(inputs: Feed_Find_Users_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Encontrar leitores`)
};
const fr_feed_find_users_cta = /** @type {(inputs: Feed_Find_Users_CtaInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Trouver des lecteurs`)
};
/**
* | output |
* | --- |
* | "Discover readers" |
*
* @param {Feed_Find_Users_CtaInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_find_users_cta = /** @type {((inputs?: Feed_Find_Users_CtaInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Find_Users_CtaInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_find_users_cta(inputs)
if (locale === "ru") return ru_feed_find_users_cta(inputs)
if (locale === "id") return id_feed_find_users_cta(inputs)
if (locale === "pt") return pt_feed_find_users_cta(inputs)
return fr_feed_find_users_cta(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_HeadingInputs */
const en_feed_heading = /** @type {(inputs: Feed_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Following Feed`)
};
const ru_feed_heading = /** @type {(inputs: Feed_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Лента подписок`)
};
const id_feed_heading = /** @type {(inputs: Feed_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Umpan Ikutan`)
};
const pt_feed_heading = /** @type {(inputs: Feed_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Feed de seguidos`)
};
const fr_feed_heading = /** @type {(inputs: Feed_HeadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Fil d'abonnements`)
};
/**
* | output |
* | --- |
* | "Following Feed" |
*
* @param {Feed_HeadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_heading = /** @type {((inputs?: Feed_HeadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_HeadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_heading(inputs)
if (locale === "ru") return ru_feed_heading(inputs)
if (locale === "id") return id_feed_heading(inputs)
if (locale === "pt") return pt_feed_heading(inputs)
return fr_feed_heading(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_Not_Logged_InInputs */
const en_feed_not_logged_in = /** @type {(inputs: Feed_Not_Logged_InInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Sign in to see your feed.`)
};
const ru_feed_not_logged_in = /** @type {(inputs: Feed_Not_Logged_InInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Войдите, чтобы видеть свою ленту.`)
};
const id_feed_not_logged_in = /** @type {(inputs: Feed_Not_Logged_InInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Masuk untuk melihat umpan Anda.`)
};
const pt_feed_not_logged_in = /** @type {(inputs: Feed_Not_Logged_InInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Faça login para ver seu feed.`)
};
const fr_feed_not_logged_in = /** @type {(inputs: Feed_Not_Logged_InInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Connectez-vous pour voir votre fil.`)
};
/**
* | output |
* | --- |
* | "Sign in to see your feed." |
*
* @param {Feed_Not_Logged_InInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_not_logged_in = /** @type {((inputs?: Feed_Not_Logged_InInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Not_Logged_InInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_not_logged_in(inputs)
if (locale === "ru") return ru_feed_not_logged_in(inputs)
if (locale === "id") return id_feed_not_logged_in(inputs)
if (locale === "pt") return pt_feed_not_logged_in(inputs)
return fr_feed_not_logged_in(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_Page_TitleInputs */
const en_feed_page_title = /** @type {(inputs: Feed_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Feed — LibNovel`)
};
const ru_feed_page_title = /** @type {(inputs: Feed_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Лента — LibNovel`)
};
const id_feed_page_title = /** @type {(inputs: Feed_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Umpan — LibNovel`)
};
const pt_feed_page_title = /** @type {(inputs: Feed_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Feed — LibNovel`)
};
const fr_feed_page_title = /** @type {(inputs: Feed_Page_TitleInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Fil — LibNovel`)
};
/**
* | output |
* | --- |
* | "Feed — LibNovel" |
*
* @param {Feed_Page_TitleInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_page_title = /** @type {((inputs?: Feed_Page_TitleInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Page_TitleInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_page_title(inputs)
if (locale === "ru") return ru_feed_page_title(inputs)
if (locale === "id") return id_feed_page_title(inputs)
if (locale === "pt") return pt_feed_page_title(inputs)
return fr_feed_page_title(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_Reader_LabelInputs */
const en_feed_reader_label = /** @type {(inputs: Feed_Reader_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`reading`)
};
const ru_feed_reader_label = /** @type {(inputs: Feed_Reader_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`читает`)
};
const id_feed_reader_label = /** @type {(inputs: Feed_Reader_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`membaca`)
};
const pt_feed_reader_label = /** @type {(inputs: Feed_Reader_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`lendo`)
};
const fr_feed_reader_label = /** @type {(inputs: Feed_Reader_LabelInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`lit`)
};
/**
* | output |
* | --- |
* | "reading" |
*
* @param {Feed_Reader_LabelInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_reader_label = /** @type {((inputs?: Feed_Reader_LabelInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_Reader_LabelInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_reader_label(inputs)
if (locale === "ru") return ru_feed_reader_label(inputs)
if (locale === "id") return id_feed_reader_label(inputs)
if (locale === "pt") return pt_feed_reader_label(inputs)
return fr_feed_reader_label(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Feed_SubheadingInputs */
const en_feed_subheading = /** @type {(inputs: Feed_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Books your followed users are reading`)
};
const ru_feed_subheading = /** @type {(inputs: Feed_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Книги, которые читают ваши подписки`)
};
const id_feed_subheading = /** @type {(inputs: Feed_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Buku yang sedang dibaca oleh pengguna yang Anda ikuti`)
};
const pt_feed_subheading = /** @type {(inputs: Feed_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Livros que seus seguidos estão lendo`)
};
const fr_feed_subheading = /** @type {(inputs: Feed_SubheadingInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Livres lus par vos abonnements`)
};
/**
* | output |
* | --- |
* | "Books your followed users are reading" |
*
* @param {Feed_SubheadingInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const feed_subheading = /** @type {((inputs?: Feed_SubheadingInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Feed_SubheadingInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_feed_subheading(inputs)
if (locale === "ru") return ru_feed_subheading(inputs)
if (locale === "id") return id_feed_subheading(inputs)
if (locale === "pt") return pt_feed_subheading(inputs)
return fr_feed_subheading(inputs)
});

View File

@@ -0,0 +1,44 @@
/* eslint-disable */
import { getLocale, experimentalStaticLocale } from '../runtime.js';
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
/** @typedef {{}} Nav_FeedInputs */
const en_nav_feed = /** @type {(inputs: Nav_FeedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Feed`)
};
const ru_nav_feed = /** @type {(inputs: Nav_FeedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Лента`)
};
const id_nav_feed = /** @type {(inputs: Nav_FeedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Umpan`)
};
const pt_nav_feed = /** @type {(inputs: Nav_FeedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Feed`)
};
const fr_nav_feed = /** @type {(inputs: Nav_FeedInputs) => LocalizedString} */ () => {
return /** @type {LocalizedString} */ (`Fil`)
};
/**
* | output |
* | --- |
* | "Feed" |
*
* @param {Nav_FeedInputs} inputs
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
* @returns {LocalizedString}
*/
export const nav_feed = /** @type {((inputs?: Nav_FeedInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Nav_FeedInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
if (locale === "en") return en_nav_feed(inputs)
if (locale === "ru") return ru_nav_feed(inputs)
if (locale === "id") return id_nav_feed(inputs)
if (locale === "pt") return pt_nav_feed(inputs)
return fr_nav_feed(inputs)
});

View File

@@ -211,6 +211,20 @@ async function listOne<T>(collection: string, filter: string, sort = ''): Promis
const BOOKS_CACHE_KEY = 'books:all';
const BOOKS_CACHE_TTL = 5 * 60; // 5 minutes
const RATINGS_CACHE_KEY = 'book_ratings:all';
const RATINGS_CACHE_TTL = 5 * 60; // 5 minutes
const HOME_STATS_CACHE_KEY = 'home:stats';
const HOME_STATS_CACHE_TTL = 10 * 60; // 10 minutes — counts don't need to be exact
async function getAllRatings(): Promise<BookRating[]> {
const cached = await cache.get<BookRating[]>(RATINGS_CACHE_KEY);
if (cached) return cached;
const ratings = await listAll<BookRating>('book_ratings', '').catch(() => [] as BookRating[]);
await cache.set(RATINGS_CACHE_KEY, ratings, RATINGS_CACHE_TTL);
return ratings;
}
export async function listBooks(): Promise<Book[]> {
const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
if (cached) {
@@ -282,7 +296,12 @@ export async function getBooksBySlugs(slugs: Iterable<string>): Promise<Book[]>
/** Invalidate the books cache (call after a book is created/updated/deleted). */
export async function invalidateBooksCache(): Promise<void> {
await cache.invalidate(BOOKS_CACHE_KEY);
await Promise.all([
cache.invalidate(BOOKS_CACHE_KEY),
cache.invalidate(HOME_STATS_CACHE_KEY),
cache.invalidatePattern('books:recent:*'),
cache.invalidatePattern('books:recently-updated:*')
]);
}
export async function getBook(slug: string): Promise<Book | null> {
@@ -290,7 +309,53 @@ export async function getBook(slug: string): Promise<Book | null> {
}
export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
return listN<Book>('books', limit, '', '-meta_updated');
const key = `books:recent:${limit}`;
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
const books = await listN<Book>('books', limit, '', '-meta_updated');
await cache.set(key, books, 5 * 60);
return books;
}
/**
* Books with the most recently added chapters, ordered by chapter insertion time.
* Queries chapters_idx sorted by -created, deduplicates by slug, then loads books.
* This correctly reflects actual chapter activity, unlike meta_updated on books.
*/
export async function recentlyUpdatedBooks(limit = 8): Promise<Book[]> {
const key = `books:recently-updated:${limit}`;
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
try {
// Fetch enough recent chapter rows to find `limit` distinct books
const rows = await listN<{ slug: string; created: string }>(
'chapters_idx', limit * 25, '', '-created'
);
const seen = new Set<string>();
const slugs: string[] = [];
for (const row of rows) {
if (!seen.has(row.slug)) {
seen.add(row.slug);
slugs.push(row.slug);
if (slugs.length >= limit) break;
}
}
if (!slugs.length) return recentlyAddedBooks(limit);
const books = await getBooksBySlugs(new Set(slugs));
// Restore recency order (getBooksBySlugs returns in title sort order)
const bookMap = new Map(books.map((b) => [b.slug, b]));
const ordered = slugs.flatMap((s) => (bookMap.has(s) ? [bookMap.get(s)!] : []));
await cache.set(key, ordered, 5 * 60);
return ordered;
} catch {
// Fall back to meta_updated sort if chapters_idx query fails
return recentlyAddedBooks(limit);
}
}
export interface HomeStats {
@@ -299,11 +364,19 @@ export interface HomeStats {
}
export async function getHomeStats(): Promise<HomeStats> {
const cached = await cache.get<HomeStats>(HOME_STATS_CACHE_KEY);
if (cached) return cached;
const [totalBooks, totalChapters] = await Promise.all([
countCollection('books'),
countCollection('chapters_idx')
]);
return { totalBooks, totalChapters };
const stats = { totalBooks, totalChapters };
await cache.set(HOME_STATS_CACHE_KEY, stats, HOME_STATS_CACHE_TTL);
return stats;
}
export async function invalidateHomeStatsCache(): Promise<void> {
await cache.invalidate(HOME_STATS_CACHE_KEY);
}
// ─── Chapter index ────────────────────────────────────────────────────────────
@@ -542,7 +615,7 @@ export async function unsaveBook(
// ─── Users ────────────────────────────────────────────────────────────────────
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex');
@@ -966,6 +1039,15 @@ export async function listAudioJobs(): Promise<AudioJob[]> {
return listAll<AudioJob>('audio_jobs', '', '-started');
}
/**
* Returns the set of book slugs that have at least one completed audio job.
* Used by the catalogue page to show audio-available badges.
*/
export async function getSlugsWithAudio(): Promise<Set<string>> {
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', 'slug');
return new Set(jobs.map((j) => j.slug));
}
// ─── Translation jobs ─────────────────────────────────────────────────────────
export interface TranslationJob {
@@ -1003,12 +1085,79 @@ export interface UserSession {
session_id: string; // the auth session ID embedded in the token
user_agent: string;
ip: string;
device_fingerprint: string;
created_at: string;
last_seen: string;
}
/**
* Create a new session record on login. Returns the record ID.
* Generate a short device fingerprint from user-agent + IP.
* SHA-256 of the concatenation, first 16 hex chars.
*/
function deviceFingerprint(userAgent: string, ip: string): string {
return createHash('sha256')
.update(`${userAgent}::${ip}`)
.digest('hex')
.slice(0, 16);
}
/**
* Upsert a session record on login.
* - If a session already exists for this user + device fingerprint, touch it and
* return the existing authSessionId (so the caller can reuse the same token).
* - Otherwise create a new record.
* Returns `{ authSessionId, recordId }`.
*/
export async function upsertUserSession(
userId: string,
authSessionId: string,
userAgent: string,
ip: string
): Promise<{ authSessionId: string; recordId: string }> {
const fp = deviceFingerprint(userAgent, ip);
// Look for an existing session from the same device
const existing = await listOne<UserSession>(
'user_sessions',
`user_id="${userId}" && device_fingerprint="${fp}"`
);
if (existing) {
// Touch last_seen and return the existing authSessionId
const token = await getToken();
await fetch(`${PB_URL}/api/collections/user_sessions/records/${existing.id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ last_seen: new Date().toISOString() })
}).catch(() => {});
return { authSessionId: existing.session_id, recordId: existing.id };
}
// Create a new session record
const now = new Date().toISOString();
const res = await pbPost('/api/collections/user_sessions/records', {
user_id: userId,
session_id: authSessionId,
user_agent: userAgent,
ip,
device_fingerprint: fp,
created_at: now,
last_seen: now
});
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'upsertUserSession POST failed', { userId, status: res.status, body });
throw new Error(`Failed to create session: ${res.status}`);
}
const rec = (await res.json()) as { id: string };
// Best-effort: prune stale/excess sessions in the background
pruneStaleUserSessions(userId).catch(() => {});
return { authSessionId, recordId: rec.id };
}
/**
* @deprecated Use upsertUserSession instead.
* Kept temporarily so callers can be migrated incrementally.
*/
export async function createUserSession(
userId: string,
@@ -1016,24 +1165,8 @@ export async function createUserSession(
userAgent: string,
ip: string
): Promise<string> {
const now = new Date().toISOString();
const res = await pbPost('/api/collections/user_sessions/records', {
user_id: userId,
session_id: authSessionId,
user_agent: userAgent,
ip,
created_at: now,
last_seen: now
});
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'createUserSession POST failed', { userId, status: res.status, body });
throw new Error(`Failed to create session: ${res.status}`);
}
const rec = (await res.json()) as { id: string };
// Best-effort: prune stale sessions in the background so the list doesn't grow forever
pruneStaleUserSessions(userId).catch(() => {});
return rec.id;
const { recordId } = await upsertUserSession(userId, authSessionId, userAgent, ip);
return recordId;
}
/**
@@ -1070,20 +1203,37 @@ export async function listUserSessions(userId: string): Promise<UserSession[]> {
}
/**
* Delete sessions for a user that haven't been seen in the last `days` days.
* Delete sessions for a user that haven't been seen in the last `days` days,
* and cap the total number of sessions at `maxSessions` (pruning oldest first).
* Called on login so the list self-cleans without a separate cron job.
*/
async function pruneStaleUserSessions(userId: string, days = 30): Promise<void> {
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
const stale = await listAll<UserSession>(
'user_sessions',
`user_id="${userId}" && last_seen<"${cutoff}"`
);
if (stale.length === 0) return;
async function pruneStaleUserSessions(
userId: string,
days = 30,
maxSessions = 10
): Promise<void> {
const token = await getToken();
const all = await listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
const toDelete = new Set<string>();
// Mark stale sessions
for (const s of all) {
if (s.last_seen < cutoff) toDelete.add(s.id);
}
// Mark excess sessions beyond the cap (oldest first — list is sorted -last_seen)
const remaining = all.filter((s) => !toDelete.has(s.id));
if (remaining.length > maxSessions) {
remaining.slice(maxSessions).forEach((s) => toDelete.add(s.id));
}
if (toDelete.size === 0) return;
await Promise.all(
stale.map((s) =>
fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, {
[...toDelete].map((id) =>
fetch(`${PB_URL}/api/collections/user_sessions/records/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
}).catch(() => {})
@@ -1781,6 +1931,7 @@ export async function setBookRating(
} else {
await pbPost('/api/collections/book_ratings/records', payload);
}
await cache.invalidate(RATINGS_CACHE_KEY);
}
// ─── Shelves ───────────────────────────────────────────────────────────────────
@@ -1847,11 +1998,167 @@ export async function getBooksForDiscovery(
if (sf.length >= 3) candidates = sf;
}
// Fisher-Yates shuffle
for (let i = candidates.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
// Fetch avg ratings for candidates, weight top-rated books to surface earlier.
// Fetch in one shot for all candidate slugs. Low-rated / unrated books still
// appear — they're just pushed further back via a stable sort before shuffle.
const ratingRows = await getAllRatings();
const ratingMap = new Map<string, { sum: number; count: number }>();
for (const r of ratingRows) {
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
cur.sum += r.rating;
cur.count += 1;
ratingMap.set(r.slug, cur);
}
const avgRating = (slug: string) => {
const e = ratingMap.get(slug);
return e && e.count > 0 ? e.sum / e.count : 0;
};
// Sort by avg desc (unrated = 0, treated as unknown → middle of pack after rated)
// Then apply Fisher-Yates only within each rating tier so ordering feels natural.
candidates.sort((a, b) => avgRating(b.slug) - avgRating(a.slug));
// Shuffle within rating tiers (±0.5 star buckets) to avoid pure determinism
const tierOf = (slug: string) => Math.round(avgRating(slug) * 2); // 010
let start = 0;
while (start < candidates.length) {
let end = start + 1;
while (end < candidates.length && tierOf(candidates[end].slug) === tierOf(candidates[start].slug)) end++;
for (let i = end - 1; i > start; i--) {
const j = start + Math.floor(Math.random() * (i - start + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
}
start = end;
}
return candidates.slice(0, 50);
}
// ─── Discovery history ─────────────────────────────────────────────────────────
export interface VotedBook {
slug: string;
action: DiscoveryVote['action'];
votedAt: string;
book?: Book;
}
export async function getVotedBooks(
sessionId: string,
userId?: string
): Promise<VotedBook[]> {
const votes = await listAll<DiscoveryVote & { id: string; created: string }>(
'discovery_votes',
discoveryFilter(sessionId, userId),
'-created'
).catch(() => []);
if (!votes.length) return [];
const slugs = [...new Set(votes.map((v) => v.slug))];
const books = await getBooksBySlugs(new Set(slugs)).catch(() => [] as Book[]);
const bookMap = new Map(books.map((b) => [b.slug, b]));
return votes.map((v) => ({
slug: v.slug,
action: v.action,
votedAt: v.created,
book: bookMap.get(v.slug)
}));
}
export async function undoDiscoveryVote(
sessionId: string,
slug: string,
userId?: string
): Promise<void> {
const filter = `${discoveryFilter(sessionId, userId)}&&slug="${slug}"`;
const row = await listOne<{ id: string }>('discovery_votes', filter).catch(() => null);
if (row) {
await pbDelete(`/api/collections/discovery_votes/records/${row.id}`).catch(() => {});
}
}
// ─── User stats ────────────────────────────────────────────────────────────────
export interface UserStats {
totalChaptersRead: number;
booksReading: number;
booksCompleted: number;
booksPlanToRead: number;
booksDropped: number;
topGenres: string[]; // top 3 by frequency
avgRatingGiven: number; // 0 if no ratings
streak: number; // consecutive days with progress
}
export async function getUserStats(
sessionId: string,
userId?: string
): Promise<UserStats> {
const filter = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;
const [progressRows, libraryRows, ratingRows, allBooks] = await Promise.all([
listAll<Progress & { updated: string }>('progress', filter, '-updated').catch(() => []),
listAll<{ slug: string; shelf: string }>('user_library', filter).catch(() => []),
listAll<BookRating>('book_ratings', filter).catch(() => []),
listBooks().catch(() => [] as Book[])
]);
// shelf counts
const shelfCounts = { reading: 0, completed: 0, plan_to_read: 0, dropped: 0 };
for (const r of libraryRows) {
const s = r.shelf || 'reading';
if (s in shelfCounts) shelfCounts[s as keyof typeof shelfCounts]++;
}
// top genres from books in progress/library
const libSlugs = new Set(libraryRows.map((r) => r.slug));
const progSlugs = new Set(progressRows.map((r) => r.slug));
const allSlugs = new Set([...libSlugs, ...progSlugs]);
const bookMap = new Map(allBooks.map((b) => [b.slug, b]));
const genreFreq = new Map<string, number>();
for (const slug of allSlugs) {
const book = bookMap.get(slug);
if (!book) continue;
for (const g of parseGenresLocal(book.genres)) {
genreFreq.set(g, (genreFreq.get(g) ?? 0) + 1);
}
}
const topGenres = [...genreFreq.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([g]) => g);
// avg rating given
const avgRatingGiven =
ratingRows.length > 0
? Math.round((ratingRows.reduce((s, r) => s + r.rating, 0) / ratingRows.length) * 10) / 10
: 0;
// reading streak: count consecutive calendar days (UTC) with a progress update
const days = new Set(
progressRows
.filter((r) => r.updated)
.map((r) => r.updated.slice(0, 10))
);
let streak = 0;
const today = new Date();
for (let i = 0; i < 365; i++) {
const d = new Date(today);
d.setUTCDate(d.getUTCDate() - i);
if (days.has(d.toISOString().slice(0, 10))) streak++;
else if (i > 0) break; // gap — stop
}
return {
totalChaptersRead: progressRows.length,
booksReading: shelfCounts.reading,
booksCompleted: shelfCounts.completed,
booksPlanToRead: shelfCounts.plan_to_read,
booksDropped: shelfCounts.dropped,
topGenres,
avgRatingGiven,
streak
};
}

View File

@@ -12,7 +12,7 @@
export interface Voice {
/** Voice identifier passed to TTS clients (e.g. "af_bella", "alba"). */
id: string;
/** TTS engine: "kokoro" | "pocket-tts". */
/** TTS engine: "kokoro" | "pocket-tts" | "cfai". */
engine: string;
/** Primary language tag (e.g. "en-us", "en-gb", "en", "es", "fr"). */
lang: string;

View File

@@ -88,6 +88,7 @@
// Apply persisted settings once on mount (server-loaded data).
// Use a derived to react to future invalidateAll() re-loads too.
let settingsApplied = false;
let settingsDirty = false; // true only after the first apply completes
$effect(() => {
if (data.settings) {
if (!settingsApplied) {
@@ -100,6 +101,9 @@
currentTheme = data.settings.theme ?? 'amber';
currentFontFamily = data.settings.fontFamily ?? 'system';
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);
}
});
@@ -114,8 +118,9 @@
const fontFamily = currentFontFamily;
const fontSize = currentFontSize;
// Skip saving until settings have been applied from the server
if (!settingsApplied) return;
// Skip saving until settings have been applied from the server AND
// at least one user-driven change has occurred after that.
if (!settingsDirty) return;
clearTimeout(settingsSaveTimer);
settingsSaveTimer = setTimeout(() => {
@@ -187,6 +192,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() {
@@ -289,6 +338,12 @@
onended={() => {
audioStore.isPlaying = false;
saveAudioTime();
// If sleep-after-chapter is set, just pause instead of navigating
if (audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = false;
// Don't navigate just let it end. Audio is already stopped (ended).
return;
}
if (audioStore.autoNext && audioStore.nextChapter !== null && audioStore.slug) {
// Capture values synchronously before any async work — the AudioPlayer
// component will unmount during navigation, but we've already read what
@@ -334,18 +389,24 @@
>
{m.nav_library()}
</a>
<a
href="/discover"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/discover') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Discover
</a>
<a
href="/catalogue"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
{m.nav_catalogue()}
</a>
<a
href="/discover"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/discover') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Discover
</a>
<a
href="/feed"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/feed') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
{m.nav_feed()}
</a>
<a
href="/catalogue"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
{m.nav_catalogue()}
</a>
<div class="ml-auto flex items-center gap-2">
<!-- Theme dropdown (desktop) -->
<div class="hidden sm:block relative">
@@ -519,20 +580,27 @@
>
{m.nav_library()}
</a>
<a
href="/discover"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/discover') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
Discover
</a>
<a
href="/catalogue"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/catalogue') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
{m.nav_catalogue()}
</a>
<a
href="/discover"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/discover') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
Discover
</a>
<a
href="/feed"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/feed') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
{m.nav_feed()}
</a>
<a
href="/catalogue"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/catalogue') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
{m.nav_catalogue()}
</a>
<a
href="https://feedback.libnovel.cc"
target="_blank"

View File

@@ -1,7 +1,7 @@
import type { PageServerLoad } from './$types';
import {
getBooksBySlugs,
recentlyAddedBooks,
recentlyUpdatedBooks,
allProgress,
getHomeStats,
getSubscriptionFeed
@@ -19,7 +19,7 @@ export const load: PageServerLoad = async ({ locals }) => {
try {
[recentBooks, progressList, stats] = await Promise.all([
recentlyAddedBooks(8),
recentlyUpdatedBooks(8).catch(() => [] as Book[]),
allProgress(locals.sessionId, locals.user?.id),
getHomeStats()
]);

View File

@@ -1,9 +1,47 @@
<script lang="ts">
import { browser } from '$app/environment';
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
let { data }: { data: PageData } = $props();
// ── Section visibility (localStorage, Svelte 5 runes) ────────────────────────
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following';
const SECTIONS_KEY = 'home_sections_v1';
const SECTION_LABELS: Record<SectionId, string> = {
'recently-updated': 'Recently Updated',
'browse-genre': 'Browse by Genre',
'from-following': 'From Following',
};
function loadHidden(): Set<SectionId> {
if (!browser) return new Set();
try {
const raw = localStorage.getItem(SECTIONS_KEY);
if (raw) return new Set(JSON.parse(raw) as SectionId[]);
} catch { /* ignore */ }
return new Set();
}
let hidden = $state<Set<SectionId>>(loadHidden());
function hide(id: SectionId) {
hidden = new Set([...hidden, id]);
if (browser) localStorage.setItem(SECTIONS_KEY, JSON.stringify([...hidden]));
}
function restore(id: SectionId) {
const next = new Set(hidden);
next.delete(id);
hidden = next;
if (browser) localStorage.setItem(SECTIONS_KEY, JSON.stringify([...next]));
}
const hiddenList = $derived(
(Object.keys(SECTION_LABELS) as SectionId[]).filter((id) => hidden.has(id))
);
function parseGenres(genres: string[] | string | null | undefined): string[] {
if (!genres) return [];
if (Array.isArray(genres)) return genres;
@@ -121,10 +159,19 @@
{/if}
<!-- ── Genre discovery strip ─────────────────────────────────────────────────── -->
{#if !hidden.has('browse-genre')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">Browse by genre</h2>
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<div class="flex items-center gap-3">
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<button type="button" onclick={() => hide('browse-genre')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
</div>
<div class="flex gap-2 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4">
{#each GENRES as genre}
@@ -135,13 +182,22 @@
{/each}
</div>
</section>
{/if}
<!-- ── Recently Updated ──────────────────────────────────────────────────────── -->
{#if dedupedRecent.length > 0}
{#if dedupedRecent.length > 0 && !hidden.has('recently-updated')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<div class="flex items-center gap-3">
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
<button type="button" onclick={() => hide('recently-updated')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each dedupedRecent as { book, count }}
@@ -182,10 +238,16 @@
{/if}
<!-- ── From Following ────────────────────────────────────────────────────────── -->
{#if data.subscriptionFeed.length > 0}
{#if data.subscriptionFeed.length > 0 && !hidden.has('from-following')}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-base font-bold text-(--color-text)">{m.home_from_following()}</h2>
<button type="button" onclick={() => hide('from-following')} title="Hide section"
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
{#each data.subscriptionFeed as { book, readerUsername }}
@@ -221,6 +283,23 @@
</div>
{/if}
<!-- ── Hidden sections restore ───────────────────────────────────────────────── -->
{#if hiddenList.length > 0}
<div class="mb-6 flex flex-wrap items-center gap-2">
<span class="text-xs text-(--color-muted)">Hidden:</span>
{#each hiddenList as id}
<button type="button" onclick={() => restore(id)}
class="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full border border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 transition-colors">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{SECTION_LABELS[id]}
</button>
{/each}
</div>
{/if}
<!-- ── Stats footer ──────────────────────────────────────────────────────────── -->
<div class="mt-6 pt-6 border-t border-(--color-border) flex items-center justify-center gap-6 text-sm text-(--color-muted)">
<span><span class="font-semibold text-(--color-text)">{data.stats.totalBooks.toLocaleString()}</span> {m.home_stat_books()}</span>

View File

@@ -6,7 +6,9 @@
{ 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() }
];
const externalLinks = [
@@ -15,7 +17,8 @@
{ href: 'https://analytics.libnovel.cc', label: () => m.admin_nav_analytics() },
{ 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://push.libnovel.cc', label: () => m.admin_nav_push() },
{ href: 'https://gitea.kalekber.cc/kamil/libnovel', label: () => m.admin_nav_gitea() }
];
interface Props {

View File

@@ -57,6 +57,12 @@
return `${m}m ${s % 60}s`;
}
function engineLabel(voice: string): string {
if (voice.startsWith('cfai:')) return 'CF AI';
if (!voice.includes('_')) return 'Pocket TTS';
return 'Kokoro';
}
// ── Audio jobs stats + filter ────────────────────────────────────────────────
let jobsQ = $state('');
let filteredJobs = $derived(
@@ -160,6 +166,7 @@
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-right">Ch.</th>
<th class="px-4 py-3 text-left">Voice</th>
<th class="px-4 py-3 text-left">Engine</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-left">Started</th>
<th class="px-4 py-3 text-left">Duration</th>
@@ -173,6 +180,7 @@
</td>
<td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs">{job.voice}</td>
<td class="px-4 py-3 text-(--color-muted) text-xs">{engineLabel(job.voice)}</td>
<td class="px-4 py-3">
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
</td>
@@ -181,7 +189,7 @@
</tr>
{#if job.error_message}
<tr class="bg-(--color-danger)/10">
<td colspan="6" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{job.error_message}</td>
<td colspan="7" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{job.error_message}</td>
</tr>
{/if}
{/each}
@@ -202,6 +210,7 @@
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{job.chapter}</span>
<span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{job.voice}</span>
<span class="text-(--color-muted)">Engine</span><span class="text-(--color-muted) text-right">{engineLabel(job.voice)}</span>
<span class="text-(--color-muted)">Started</span><span class="text-(--color-muted) text-right">{fmtDate(job.started)}</span>
<span class="text-(--color-muted)">Duration</span><span class="text-(--color-muted) text-right">{duration(job.started, job.finished)}</span>
</div>
@@ -236,6 +245,7 @@
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-left">Chapter</th>
<th class="px-4 py-3 text-left">Voice</th>
<th class="px-4 py-3 text-left">Engine</th>
<th class="px-4 py-3 text-left">Filename</th>
<th class="px-4 py-3 text-left">Updated</th>
</tr>
@@ -249,6 +259,7 @@
</td>
<td class="px-4 py-3 text-(--color-muted)">{parts.chapter}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs">{parts.voice}</td>
<td class="px-4 py-3 text-(--color-muted) text-xs">{engineLabel(parts.voice)}</td>
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs truncate max-w-[14rem]" title={entry.filename}>
{entry.filename}
</td>
@@ -267,11 +278,12 @@
<a href="/books/{parts.slug}" class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors block truncate">
{parts.slug}
</a>
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{parts.chapter}</span>
<span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{parts.voice}</span>
<span class="text-(--color-muted)">Updated</span><span class="text-(--color-muted) text-right">{fmtDate(entry.updated)}</span>
</div>
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-(--color-muted)">Chapter</span><span class="text-(--color-muted) text-right">{parts.chapter}</span>
<span class="text-(--color-muted)">Voice</span><span class="text-(--color-muted) font-mono text-right truncate">{parts.voice}</span>
<span class="text-(--color-muted)">Engine</span><span class="text-(--color-muted) text-right">{engineLabel(parts.voice)}</span>
<span class="text-(--color-muted)">Updated</span><span class="text-(--color-muted) text-right">{fmtDate(entry.updated)}</span>
</div>
{#if entry.filename}
<p class="text-xs text-(--color-muted) font-mono truncate" title={entry.filename}>{entry.filename}</p>
{/if}

View File

@@ -1,5 +1,3 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import type { PageServerLoad } from './$types';
export interface Release {
@@ -12,13 +10,18 @@ export interface Release {
draft: boolean;
}
export const load: PageServerLoad = async () => {
const GITEA_RELEASES_URL =
'https://gitea.kalekber.cc/api/v1/repos/kamil/libnovel/releases?limit=50&page=1';
export const load: PageServerLoad = async ({ fetch }) => {
try {
// releases.json is baked into the image at build time by CI.
// SvelteKit Node adapter copies static/ → build/client/, so the file
// lives at <cwd>/build/client/releases.json in production.
const raw = readFileSync(join(process.cwd(), 'build', 'client', 'releases.json'), 'utf-8');
const releases: Release[] = JSON.parse(raw);
const res = await fetch(GITEA_RELEASES_URL, {
headers: { Accept: 'application/json' }
});
if (!res.ok) {
return { releases: [], error: `Gitea API returned ${res.status}` };
}
const releases: Release[] = await res.json();
return { releases: releases.filter((r) => !r.draft) };
} catch (e) {
return { releases: [], error: String(e) };

View File

@@ -0,0 +1,28 @@
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;
supports_ref: boolean;
recommended_for: string[]; // "cover" | "chapter"
description: string;
}
export const load: PageServerLoad = async () => {
// parent layout already guards admin role
try {
const res = await backendFetch('/api/admin/image-gen/models');
if (!res.ok) {
log.warn('admin/image-gen', 'failed to load models', { status: res.status });
return { models: [] as ImageModelInfo[] };
}
const data = await res.json();
return { models: (data.models ?? []) as ImageModelInfo[] };
} catch (e) {
log.warn('admin/image-gen', 'backend unreachable', { err: String(e) });
return { models: [] as ImageModelInfo[] };
}
};

View File

@@ -0,0 +1,660 @@
<script lang="ts">
import type { PageData } from './$types';
import type { ImageModelInfo } from './+page.server';
let { data }: { data: PageData } = $props();
// ── Form state ───────────────────────────────────────────────────────────────
type ImageType = 'cover' | 'chapter';
let imageType = $state<ImageType>('cover');
let slug = $state('');
let chapter = $state<number>(1);
let selectedModel = $state('');
let prompt = $state('');
let referenceFile = $state<File | null>(null);
let referencePreviewUrl = $state('');
// Advanced
let showAdvanced = $state(false);
let numSteps = $state(20);
let guidance = $state(7.5);
let strength = $state(0.75);
let width = $state(1024);
let height = $state(1024);
// ── 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 ────────────────────────────────────────────────────────────
const models = data.models as ImageModelInfo[];
let filteredModels = $derived(
referenceFile
? models // show all; warn on ones without ref support
: models
);
let coverModels = $derived(filteredModels.filter((m) => m.recommended_for.includes('cover')));
let chapterModels = $derived(filteredModels.filter((m) => m.recommended_for.includes('chapter')));
let otherModels = $derived(
filteredModels.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;
}
});
// Reset model selection when type changes if current selection no longer fits
$effect(() => {
void imageType; // track
const preferred = imageType === 'cover' ? coverModels : chapterModels;
if (preferred.length > 0) {
// only auto-switch if current model isn't in preferred list for this type
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 "${slug || '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 "${slug || 'untitled novel'}". Dramatic moment, vivid colors, anime-inspired style, detailed background, cinematic lighting.`
);
function applyTemplate() {
prompt = promptTemplate;
}
// ── 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);
}
function onDrop(e: DragEvent) {
e.preventDefault();
dragOver = false;
const file = e.dataTransfer?.files[0];
if (file && file.type.startsWith('image/')) handleReferenceFile(file);
}
function clearReference() {
handleReferenceFile(null);
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 {
// Extract the raw base64 from the data URL (data:<mime>;base64,<b64>)
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>
<input
id="slug-input"
type="text"
bind:value={slug}
placeholder="e.g. shadow-slave"
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)"
/>
</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-1">
<div class="flex items-center justify-between">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="prompt-input">
Prompt
</label>
<button
onclick={applyTemplate}
class="text-xs text-(--color-brand) hover:text-(--color-brand-dim) transition-colors"
>
Use template
</button>
</div>
<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>
<!-- Reference image drop zone -->
<div class="space-y-1">
<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 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>
</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 class="text-xs text-(--color-muted)">Steps</label>
<span class="text-xs text-(--color-text) font-mono">{numSteps}</span>
</div>
<input 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 class="text-xs text-(--color-muted)">Guidance</label>
<span class="text-xs text-(--color-text) font-mono">{guidance.toFixed(1)}</span>
</div>
<input 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 class="text-xs text-(--color-muted)">Strength</label>
<span class="text-xs text-(--color-text) font-mono">{strength.toFixed(2)}</span>
</div>
<input 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}
<!-- Spinner -->
<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="Generated image"
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,27 @@
import type { PageServerLoad } from './$types';
import { backendFetch } from '$lib/server/scraper';
import { log } from '$lib/server/logger';
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.
try {
const res = await backendFetch('/api/admin/text-gen/models');
if (!res.ok) {
log.warn('admin/text-gen', 'failed to load models', { status: res.status });
return { models: [] as TextModelInfo[] };
}
const data = await res.json();
return { models: (data.models ?? []) as TextModelInfo[] };
} catch (e) {
log.warn('admin/text-gen', 'backend unreachable', { err: String(e) });
return { models: [] as TextModelInfo[] };
}
};

View File

@@ -0,0 +1,516 @@
<script lang="ts">
import type { PageData } from './$types';
import type { TextModelInfo } from './+page.server';
let { data }: { data: PageData } = $props();
const models = data.models as TextModelInfo[];
// ── Shared ────────────────────────────────────────────────────────────────────
type ActiveTab = 'chapters' | 'description';
let activeTab = $state<ActiveTab>('chapters');
let selectedModel = $state(models[0]?.id ?? '');
let selectedModelInfo = $derived(models.find((m) => m.id === selectedModel) ?? null);
function fmtCtx(n: number) {
if (n >= 1000) return `${(n / 1000).toFixed(0)}k ctx`;
return `${n} ctx`;
}
// ── Chapter names state ───────────────────────────────────────────────────────
let chSlug = $state('');
let chPattern = $state('Chapter {n}: {scene}');
let chGenerating = $state(false);
let chError = $state('');
interface ProposedChapter {
number: number;
old_title: string;
new_title: string;
// editable copy
edited: string;
}
let chProposals = $state<ProposedChapter[]>([]);
let chRawResponse = $state('');
let chUsedModel = $state('');
let chShowRaw = $state(false);
let chApplying = $state(false);
let chApplyError = $state('');
let chApplySuccess = $state(false);
let chCanGenerate = $derived(chSlug.trim().length > 0 && chPattern.trim().length > 0 && !chGenerating);
let chCanApply = $derived(chProposals.length > 0 && !chApplying);
async function generateChapterNames() {
if (!chCanGenerate) return;
chGenerating = true;
chError = '';
chProposals = [];
chApplySuccess = false;
chApplyError = '';
try {
const res = await fetch('/api/admin/text-gen/chapter-names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: chSlug.trim(),
pattern: chPattern.trim(),
model: selectedModel
})
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
chError = body.error ?? body.message ?? `Error ${res.status}`;
return;
}
chProposals = ((body.chapters ?? []) as { number: number; old_title: string; new_title: string }[]).map(
(p) => ({ ...p, edited: p.new_title })
);
chRawResponse = body.raw_response ?? '';
chUsedModel = body.model ?? '';
// If backend returned chapters:[] but we have a raw response, the model
// output was unparseable (likely truncated). Treat it as an error.
if (chProposals.length === 0 && chRawResponse.trim().length > 0) {
chError = 'Model response could not be parsed (output may be truncated). Raw response shown below.';
}
} catch {
chError = 'Network error.';
} finally {
chGenerating = false;
}
}
async function applyChapterNames() {
if (!chCanApply) return;
chApplying = true;
chApplyError = '';
chApplySuccess = false;
try {
const res = await fetch('/api/admin/text-gen/chapter-names/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: chSlug.trim(),
chapters: chProposals.map((p) => ({ number: p.number, title: p.edited.trim() || p.new_title }))
})
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
chApplyError = body.error ?? body.message ?? `Error ${res.status}`;
return;
}
chApplySuccess = true;
} catch {
chApplyError = 'Network error.';
} finally {
chApplying = false;
}
}
// ── Description state ─────────────────────────────────────────────────────────
let dSlug = $state('');
let dInstructions = $state('');
let dGenerating = $state(false);
let dError = $state('');
let dOldDesc = $state('');
let dNewDesc = $state('');
let dUsedModel = $state('');
let dApplying = $state(false);
let dApplyError = $state('');
let dApplySuccess = $state(false);
let dCanGenerate = $derived(dSlug.trim().length > 0 && !dGenerating);
let dCanApply = $derived(dNewDesc.trim().length > 0 && !dApplying);
async function generateDescription() {
if (!dCanGenerate) return;
dGenerating = true;
dError = '';
dOldDesc = '';
dNewDesc = '';
dApplySuccess = false;
dApplyError = '';
try {
const res = await fetch('/api/admin/text-gen/description', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: dSlug.trim(),
instructions: dInstructions.trim(),
model: selectedModel
})
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
dError = body.error ?? body.message ?? `Error ${res.status}`;
return;
}
dOldDesc = body.old_description ?? '';
dNewDesc = body.new_description ?? '';
dUsedModel = body.model ?? '';
} catch {
dError = 'Network error.';
} finally {
dGenerating = false;
}
}
async function applyDescription() {
if (!dCanApply) return;
dApplying = true;
dApplyError = '';
dApplySuccess = false;
try {
const res = await fetch('/api/admin/text-gen/description/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: dSlug.trim(),
description: dNewDesc.trim()
})
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
dApplyError = body.error ?? body.message ?? `Error ${res.status}`;
return;
}
dApplySuccess = true;
} catch {
dApplyError = 'Network error.';
} finally {
dApplying = false;
}
}
</script>
<svelte:head>
<title>Text Gen — Admin</title>
</svelte:head>
<div class="space-y-6 max-w-5xl">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-(--color-text)">Text Generation</h1>
<p class="text-(--color-muted) text-sm mt-1">
Generate chapter titles and book descriptions using Cloudflare Workers AI.
</p>
</div>
<!-- Model selector (shared) -->
<div class="space-y-1 max-w-md">
<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)"
>
{#each models as m}
<option value={m.id}>{m.label} {m.provider} ({fmtCtx(m.context_size)})</option>
{/each}
</select>
{#if selectedModelInfo}
<p class="text-xs text-(--color-muted)">{selectedModelInfo.description}</p>
{/if}
</div>
<!-- Tab toggle -->
<div class="flex gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
{#each (['chapters', 'description'] as const) as t}
<button
onclick={() => (activeTab = t)}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{activeTab === t
? 'bg-(--color-surface-3) text-(--color-text)'
: 'text-(--color-muted) hover:text-(--color-text)'}"
>
{t === 'chapters' ? 'Chapter Names' : 'Description'}
</button>
{/each}
</div>
<!-- ── Chapter names panel ────────────────────────────────────────────────── -->
{#if activeTab === 'chapters'}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<!-- Left: form -->
<div class="space-y-4">
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-slug">
Book slug
</label>
<input
id="ch-slug"
type="text"
bind:value={chSlug}
placeholder="e.g. shadow-slave"
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)"
/>
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="ch-pattern">
Naming pattern
</label>
<input
id="ch-pattern"
type="text"
bind:value={chPattern}
placeholder="e.g. Chapter [n]: [brief scene description]"
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)"
/>
<p class="text-xs text-(--color-muted)">
Describe the desired chapter title format. The AI will rename every chapter to match.
</p>
</div>
<button
onclick={generateChapterNames}
disabled={!chCanGenerate}
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 chGenerating}
<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…
{:else}
Generate proposals
{/if}
</button>
{#if chError}
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{chError}</p>
{#if chRawResponse}
<pre class="text-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg p-3 overflow-auto max-h-48 text-(--color-muted) whitespace-pre-wrap break-words">{chRawResponse}</pre>
{/if}
{/if}
</div>
<!-- Right: proposals -->
<div class="space-y-4">
{#if chProposals.length > 0}
<div class="space-y-2">
<div class="flex items-center justify-between">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
{chProposals.length} proposals
{#if chUsedModel}
<span class="normal-case font-normal">· {chUsedModel.split('/').pop()}</span>
{/if}
</p>
<button
onclick={() => (chShowRaw = !chShowRaw)}
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors"
>
{chShowRaw ? 'Hide raw' : 'Show raw'}
</button>
</div>
{#if chShowRaw}
<pre class="text-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg p-3 overflow-auto max-h-40 text-(--color-muted)">{chRawResponse}</pre>
{/if}
<div class="max-h-[28rem] overflow-y-auto space-y-2 pr-1">
{#each chProposals as proposal}
<div class="bg-(--color-surface) border border-(--color-border) rounded-lg p-3 space-y-1.5">
<div class="flex items-center gap-2">
<span class="text-xs font-mono text-(--color-muted) w-8 shrink-0">#{proposal.number}</span>
<p class="text-xs text-(--color-muted) line-through truncate flex-1" title={proposal.old_title}>
{proposal.old_title}
</p>
</div>
<input
type="text"
bind:value={proposal.edited}
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-md px-2.5 py-1.5 text-(--color-text) text-sm focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
/>
</div>
{/each}
</div>
<div class="flex gap-2 pt-1">
<button
onclick={applyChapterNames}
disabled={!chCanApply}
class="flex-1 py-2 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"
>
{chApplying ? 'Saving…' : chApplySuccess ? 'Saved ✓' : `Apply ${chProposals.length} titles`}
</button>
<button
onclick={() => { chProposals = []; chRawResponse = ''; chApplySuccess = false; }}
class="px-4 py-2 rounded-lg bg-(--color-surface-2) text-(--color-muted) text-sm
hover:text-(--color-text) transition-colors border border-(--color-border)"
>
Clear
</button>
</div>
{#if chApplyError}
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{chApplyError}</p>
{/if}
{#if chApplySuccess}
<p class="text-sm text-green-400 bg-green-400/10 rounded-lg px-3 py-2">
{chProposals.length} chapter titles saved successfully.
</p>
{/if}
</div>
{:else if chGenerating}
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-48">
<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 chapter titles…</p>
</div>
</div>
{:else}
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-48">
<p class="text-sm text-(--color-muted)">Proposed titles will appear here</p>
</div>
{/if}
</div>
</div>
{/if}
<!-- ── Description panel ──────────────────────────────────────────────────── -->
{#if activeTab === 'description'}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<!-- Left: form -->
<div class="space-y-4">
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="d-slug">
Book slug
</label>
<input
id="d-slug"
type="text"
bind:value={dSlug}
placeholder="e.g. shadow-slave"
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)"
/>
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-(--color-muted) uppercase tracking-wide" for="d-instructions">
Instructions <span class="normal-case font-normal text-(--color-muted)">(optional)</span>
</label>
<textarea
id="d-instructions"
bind:value={dInstructions}
rows="3"
placeholder="e.g. Write a 3-sentence blurb, avoid spoilers, dramatic tone."
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>
<button
onclick={generateDescription}
disabled={!dCanGenerate}
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 dGenerating}
<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…
{:else}
Generate description
{/if}
</button>
{#if dError}
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{dError}</p>
{/if}
</div>
<!-- Right: result -->
<div class="space-y-4">
{#if dNewDesc || dOldDesc}
<div class="space-y-4">
{#if dOldDesc}
<div class="space-y-1">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Current description</p>
<div class="bg-(--color-surface) border border-(--color-border) rounded-lg p-3">
<p class="text-sm text-(--color-muted) whitespace-pre-wrap">{dOldDesc}</p>
</div>
</div>
{/if}
{#if dNewDesc}
<div class="space-y-1">
<div class="flex items-center justify-between">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest">
Proposed description
{#if dUsedModel}
<span class="normal-case font-normal">· {dUsedModel.split('/').pop()}</span>
{/if}
</p>
</div>
<textarea
bind:value={dNewDesc}
rows="6"
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) resize-y"
></textarea>
</div>
<div class="flex gap-2">
<button
onclick={applyDescription}
disabled={!dCanApply}
class="flex-1 py-2 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"
>
{dApplying ? 'Saving…' : dApplySuccess ? 'Saved ✓' : 'Apply description'}
</button>
<button
onclick={() => { dNewDesc = ''; dOldDesc = ''; dApplySuccess = false; }}
class="px-4 py-2 rounded-lg bg-(--color-surface-2) text-(--color-muted) text-sm
hover:text-(--color-text) transition-colors border border-(--color-border)"
>
Clear
</button>
</div>
{#if dApplyError}
<p class="text-sm text-(--color-danger) bg-(--color-danger)/10 rounded-lg px-3 py-2">{dApplyError}</p>
{/if}
{#if dApplySuccess}
<p class="text-sm text-green-400 bg-green-400/10 rounded-lg px-3 py-2">Description saved successfully.</p>
{/if}
{/if}
</div>
{:else if dGenerating}
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) rounded-xl h-48">
<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 description…</p>
</div>
</div>
{:else}
<div class="flex items-center justify-center bg-(--color-surface) border border-(--color-border) border-dashed rounded-xl h-48">
<p class="text-sm text-(--color-muted)">Proposed description will appear here</p>
</div>
{/if}
</div>
</div>
{/if}
</div>

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,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,33 @@
/**
* POST /api/admin/text-gen/chapter-names
*
* Admin-only proxy to the Go backend's chapter-name generation endpoint.
* Returns AI-proposed chapter titles; does NOT persist anything.
*/
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/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');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,32 @@
/**
* POST /api/admin/text-gen/chapter-names/apply
*
* Admin-only proxy to persist confirmed chapter titles to PocketBase.
*/
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/text-gen/chapter-names/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/text-gen/chapter-names/apply', '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,33 @@
/**
* POST /api/admin/text-gen/description
*
* Admin-only proxy to the Go backend's book description generation endpoint.
* Returns an AI-proposed description; does NOT persist anything.
*/
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/text-gen/description', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/text-gen/description', '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,32 @@
/**
* POST /api/admin/text-gen/description/apply
*
* Admin-only proxy to persist the confirmed book description to PocketBase.
*/
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/text-gen/description/apply', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body
});
} catch (e) {
log.error('admin/text-gen/description/apply', '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,17 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
let res: Response;
try {
res = await backendFetch('/api/admin/text-gen/models');
} catch {
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,134 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
import * as cache from '$lib/server/cache';
const FREE_DAILY_AUDIO_LIMIT = 3;
function dailyAudioKey(identifier: string): string {
const today = new Date().toISOString().slice(0, 10);
return `audio:daily:${identifier}:${today}`;
}
/**
* Return the number of audio chapters a user/session has generated today,
* and increment the counter. Shared logic with POST /api/audio/[slug]/[n].
*
* Key: audio:daily:<userId|sessionId>:<YYYY-MM-DD>
*/
async function incrementDailyAudioCount(identifier: string): Promise<number> {
const key = dailyAudioKey(identifier);
const now = new Date();
const endOfDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
const ttl = Math.ceil((endOfDay.getTime() - now.getTime()) / 1000);
try {
const raw = await cache.get<number>(key);
const current = (raw ?? 0) + 1;
await cache.set(key, current, ttl);
return current;
} catch {
// On cache failure, fail open (don't block audio for cache errors)
return 0;
}
}
/**
* GET /api/audio-stream/[slug]/[n]?voice=...
*
* Proxies the backend's streaming TTS endpoint to the browser.
*
* Fast path: if audio already in MinIO, backend sends a 302 → fetch follows it
* and we stream the MinIO audio through.
*
* Slow path (Kokoro/PocketTTS): backend streams audio bytes as they are
* generated. The browser receives the first bytes within seconds and can start
* playing before generation completes. MinIO upload happens concurrently
* server-side so subsequent requests use the cached fast path.
*
* Slow path (CF AI): backend buffers the full response (batch API limitation)
* before sending — effectively the same as the old POST+poll approach but
* without the separate progress-bar flow. Prefer the traditional POST route
* for CF AI voices to preserve the generating UI.
*/
export const GET: RequestHandler = async ({ params, url, locals }) => {
const { slug, n } = params;
const chapter = parseInt(n, 10);
if (!slug || !chapter || chapter < 1) {
error(400, 'Invalid slug or chapter number');
}
const voice = url.searchParams.get('voice') ?? '';
// ── Paywall: 3 audio chapters/day for free users ─────────────────────────
// Only count when the audio is not already cached — same rule as POST route.
if (!locals.isPro) {
const statusRes = await backendFetch(
`/api/audio/status/${slug}/${chapter}${voice ? `?voice=${encodeURIComponent(voice)}` : ''}`
).catch(() => null);
const statusData = statusRes?.ok
? ((await statusRes.json().catch(() => ({}))) as { status?: string })
: {};
if (statusData.status !== 'done') {
const identifier = locals.user?.id ?? locals.sessionId;
const count = await incrementDailyAudioCount(identifier);
if (count > FREE_DAILY_AUDIO_LIMIT) {
log.info('polar', 'free audio stream limit reached', { identifier, count });
return new Response(
JSON.stringify({ error: 'pro_required', limit: FREE_DAILY_AUDIO_LIMIT }),
{ status: 402, headers: { 'Content-Type': 'application/json' } }
);
}
}
}
const qs = new URLSearchParams({ format: 'mp3' });
if (voice) qs.set('voice', voice);
// fetch() follows the backend's 302 (MinIO fast path) automatically.
const backendRes = await backendFetch(`/api/audio-stream/${slug}/${chapter}?${qs}`);
if (!backendRes.ok) {
const text = await backendRes.text().catch(() => '');
log.error('audio-stream', 'backend stream failed', {
slug,
chapter,
status: backendRes.status,
body: text
});
error(backendRes.status as Parameters<typeof error>[0], text || 'Audio stream failed');
}
// Stream the response body directly — no buffering.
return new Response(backendRes.body, {
status: 200,
headers: {
'Content-Type': backendRes.headers.get('Content-Type') ?? 'audio/mpeg',
'Cache-Control': 'no-store',
'X-Accel-Buffering': 'no'
}
});
};
/**
* HEAD /api/audio-stream/[slug]/[n]?voice=...
*
* Paywall pre-check without incrementing the daily counter or triggering
* any generation. The AudioPlayer uses this to surface the upgrade CTA
* before pointing the <audio> element at the streaming URL.
*
* Returns 402 if the user has already hit their daily limit, 200 otherwise.
*/
export const HEAD: RequestHandler = async ({ locals }) => {
if (locals.isPro) {
return new Response(null, { status: 200 });
}
const identifier = locals.user?.id ?? locals.sessionId;
const count = (await cache.get<number>(dailyAudioKey(identifier))) ?? 0;
// count >= limit means the next GET would exceed the limit after increment
if (count >= FREE_DAILY_AUDIO_LIMIT) {
return new Response(null, { status: 402 });
}
return new Response(null, { status: 200 });
};

View File

@@ -0,0 +1,16 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getSlugsWithAudio } from '$lib/server/pocketbase';
/**
* GET /api/audio/slugs
* Returns the list of book slugs that have at least one completed audio chapter.
* Cached for 5 minutes at the CDN/proxy level.
*/
export const GET: RequestHandler = async () => {
const slugs = await getSlugsWithAudio().catch(() => new Set<string>());
return json(
{ slugs: [...slugs] },
{ headers: { 'Cache-Control': 'public, max-age=300' } }
);
};

View File

@@ -1,6 +1,6 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { loginUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase';
import { loginUser, mergeSessionProgress, upsertUserSession } from '$lib/server/pocketbase';
import { createAuthToken } from '../../../../hooks.server';
import { log } from '$lib/server/logger';
import { randomBytes } from 'node:crypto';
@@ -48,16 +48,20 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
log.warn('api/auth/login', 'mergeSessionProgress failed (non-fatal)', { err: String(e) })
);
const authSessionId = randomBytes(16).toString('hex');
const candidateSessionId = randomBytes(16).toString('hex');
const userAgent = request.headers.get('user-agent') ?? '';
const ip =
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
request.headers.get('x-real-ip') ??
'';
createUserSession(user.id, authSessionId, userAgent, ip).catch((e) =>
log.warn('api/auth/login', 'createUserSession failed (non-fatal)', { err: String(e) })
);
let authSessionId = candidateSessionId;
try {
({ authSessionId } = await upsertUserSession(user.id, candidateSessionId, userAgent, ip));
} catch (e) {
log.warn('api/auth/login', 'upsertUserSession failed (non-fatal)', { err: String(e) });
}
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);

View File

@@ -1,6 +1,6 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { upsertDiscoveryVote, clearDiscoveryVotes, saveBook } from '$lib/server/pocketbase';
import { upsertDiscoveryVote, clearDiscoveryVotes, undoDiscoveryVote, saveBook } from '$lib/server/pocketbase';
const VALID_ACTIONS = ['like', 'skip', 'nope', 'read_now'] as const;
type Action = (typeof VALID_ACTIONS)[number];
@@ -22,9 +22,16 @@ export const POST: RequestHandler = async ({ request, locals }) => {
return json({ ok: true });
};
export const DELETE: RequestHandler = async ({ locals }) => {
// DELETE /api/discover/vote → clear all (deck reset)
// DELETE /api/discover/vote?slug=... → undo single vote
export const DELETE: RequestHandler = async ({ url, locals }) => {
const slug = url.searchParams.get('slug');
try {
await clearDiscoveryVotes(locals.sessionId, locals.user?.id);
if (slug) {
await undoDiscoveryVote(locals.sessionId, slug, locals.user?.id);
} else {
await clearDiscoveryVotes(locals.sessionId, locals.user?.id);
}
} catch {
error(500, 'Failed to clear votes');
}

View File

@@ -43,27 +43,27 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
error(400, 'Invalid body — expected { autoNext, voice, speed }');
}
// theme is optional — if provided it must be a known value
// theme is optional — if provided (and non-empty) it must be a known value
const validThemes = ['amber', 'slate', 'rose', 'light', 'light-slate', 'light-rose'];
if (body.theme !== undefined && !validThemes.includes(body.theme)) {
if (body.theme !== undefined && body.theme !== '' && !validThemes.includes(body.theme)) {
error(400, `Invalid theme — must be one of: ${validThemes.join(', ')}`);
}
// locale is optional — if provided it must be a known value
// locale is optional — if provided (and non-empty) it must be a known value
const validLocales = ['en', 'ru', 'id', 'pt', 'fr'];
if (body.locale !== undefined && !validLocales.includes(body.locale)) {
if (body.locale !== undefined && body.locale !== '' && !validLocales.includes(body.locale)) {
error(400, `Invalid locale — must be one of: ${validLocales.join(', ')}`);
}
// fontFamily is optional — if provided it must be a known value
// fontFamily is optional — if provided (and non-empty) it must be a known value
const validFontFamilies = ['system', 'serif', 'mono'];
if (body.fontFamily !== undefined && !validFontFamilies.includes(body.fontFamily)) {
if (body.fontFamily !== undefined && body.fontFamily !== '' && !validFontFamilies.includes(body.fontFamily)) {
error(400, `Invalid fontFamily — must be one of: ${validFontFamilies.join(', ')}`);
}
// fontSize is optional — if provided it must be one of the valid steps
// fontSize is optional — if provided (and non-zero) it must be one of the valid steps
const validFontSizes = [0.9, 1.0, 1.15, 1.3];
if (body.fontSize !== undefined && !validFontSizes.includes(body.fontSize)) {
if (body.fontSize !== undefined && body.fontSize !== 0 && !validFontSizes.includes(body.fontSize)) {
error(400, `Invalid fontSize — must be one of: ${validFontSizes.join(', ')}`);
}

View File

@@ -25,7 +25,7 @@ import {
linkOAuthToUser
} from '$lib/server/pocketbase';
import { createAuthToken } from '../../../../hooks.server';
import { createUserSession, touchUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
import { upsertUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
type Provider = 'google' | 'github';
@@ -159,7 +159,7 @@ function deriveUsername(name: string, email: string): string {
// ─── Handler ──────────────────────────────────────────────────────────────────
export const GET: RequestHandler = async ({ params, url, cookies, locals }) => {
export const GET: RequestHandler = async ({ params, url, cookies, locals, request }) => {
const provider = params.provider as Provider;
if (provider !== 'google' && provider !== 'github') {
error(404, 'Unknown OAuth provider');
@@ -226,21 +226,19 @@ export const GET: RequestHandler = async ({ params, url, cookies, locals }) => {
log.warn('oauth', 'mergeSessionProgress failed (non-fatal)', { err: String(err) })
);
// ── Create session + auth cookie ──────────────────────────────────────────
// ── Create / reuse session + auth cookie ─────────────────────────────────
const userAgent = request.headers.get('user-agent') ?? '';
const ip =
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
request.headers.get('x-real-ip') ??
'';
const candidateSessionId = randomBytes(16).toString('hex');
let authSessionId: string;
// Reuse existing session if the user is already logged in as the same user
if (locals.user?.id === user.id && locals.user?.authSessionId) {
authSessionId = locals.user.authSessionId;
// Just touch the existing session to update last_seen
touchUserSession(authSessionId).catch(() => {});
} else {
authSessionId = randomBytes(16).toString('hex');
const userAgent = ''; // not available in RequestHandler — omit
const ip = '';
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
log.warn('oauth', 'createUserSession failed (non-fatal)', { err: String(err) })
);
try {
({ authSessionId } = await upsertUserSession(user.id, candidateSessionId, userAgent, ip));
} catch (err) {
log.warn('oauth', 'upsertUserSession failed (non-fatal)', { err: String(err) });
authSessionId = candidateSessionId;
}
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);

View File

@@ -133,6 +133,319 @@
// ── Admin panel expand/collapse ───────────────────────────────────────────
let adminOpen = $state(false);
// ── Admin: book cover generation ──────────────────────────────────────────
let coverGenerating = $state(false);
let coverPreview = $state<string | null>(null);
let coverSaving = $state(false);
let coverResult = $state<'saved' | 'error' | ''>('');
async function generateCover() {
const slug = data.book?.slug;
if (coverGenerating || !slug) return;
coverGenerating = true;
coverPreview = null;
coverResult = '';
try {
const res = await fetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, type: 'cover', prompt: data.book?.title ?? slug })
});
if (res.ok) {
const d = await res.json();
coverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
} else {
coverResult = 'error';
}
} catch {
coverResult = 'error';
} finally {
coverGenerating = false;
}
}
async function saveCover() {
const slug = data.book?.slug;
if (coverSaving || !coverPreview || !slug) return;
coverSaving = true;
coverResult = '';
try {
const b64 = coverPreview.replace(/^data:[^;]+;base64,/, '');
const res = await fetch('/api/admin/image-gen/save-cover', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, image_b64: b64 })
});
if (res.ok) {
coverResult = 'saved';
coverPreview = null;
await invalidateAll();
} else {
coverResult = 'error';
}
} catch {
coverResult = 'error';
} finally {
coverSaving = false;
}
}
// ── Admin: chapter cover generation ───────────────────────────────────────
let chapterCoverN = $state('1');
let chapterCoverGenerating = $state(false);
let chapterCoverPreview = $state<string | null>(null);
let chapterCoverResult = $state<'error' | ''>('');
async function generateChapterCover() {
const slug = data.book?.slug;
if (chapterCoverGenerating || !slug) return;
const n = parseInt(chapterCoverN, 10);
if (!n || n < 1) return;
chapterCoverGenerating = true;
chapterCoverPreview = null;
chapterCoverResult = '';
try {
const res = await fetch('/api/admin/image-gen', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, type: 'chapter', chapter: n, prompt: data.book?.title ?? slug })
});
if (res.ok) {
const d = await res.json();
chapterCoverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null;
} else {
chapterCoverResult = 'error';
}
} catch {
chapterCoverResult = 'error';
} finally {
chapterCoverGenerating = false;
}
}
// ── Admin: description generation ─────────────────────────────────────────
let descGenerating = $state(false);
let descPreview = $state('');
let descApplying = $state(false);
let descResult = $state<'applied' | 'error' | ''>('');
async function generateDesc() {
const slug = data.book?.slug;
if (descGenerating || !slug) return;
descGenerating = true;
descPreview = '';
descResult = '';
try {
const res = await fetch('/api/admin/text-gen/description', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug })
});
if (res.ok) {
const d = await res.json();
descPreview = d.new_description ?? '';
} else {
descResult = 'error';
}
} catch {
descResult = 'error';
} finally {
descGenerating = false;
}
}
async function applyDesc() {
const slug = data.book?.slug;
if (descApplying || !descPreview || !slug) return;
descApplying = true;
descResult = '';
try {
const res = await fetch('/api/admin/text-gen/description/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, description: descPreview })
});
if (res.ok) {
descResult = 'applied';
descPreview = '';
await invalidateAll();
} else {
descResult = 'error';
}
} catch {
descResult = 'error';
} finally {
descApplying = false;
}
}
// ── Admin: chapter names generation ───────────────────────────────────────
let chapNamesGenerating = $state(false);
let chapNamesPreview = $state<{ number: number; old_title: string; new_title: string }[]>([]);
let chapNamesApplying = $state(false);
let chapNamesResult = $state<'applied' | 'error' | ''>('');
async function generateChapNames() {
const slug = data.book?.slug;
if (chapNamesGenerating || !slug) return;
chapNamesGenerating = true;
chapNamesPreview = [];
chapNamesResult = '';
try {
const res = await fetch('/api/admin/text-gen/chapter-names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, pattern: 'Chapter {n}: {scene}' })
});
if (res.ok) {
const d = await res.json();
chapNamesPreview = d.chapters ?? [];
} else {
chapNamesResult = 'error';
}
} catch {
chapNamesResult = 'error';
} finally {
chapNamesGenerating = false;
}
}
async function applyChapNames() {
const slug = data.book?.slug;
if (chapNamesApplying || chapNamesPreview.length === 0 || !slug) return;
chapNamesApplying = true;
chapNamesResult = '';
try {
const chapters = chapNamesPreview.map((c) => ({ number: c.number, title: c.new_title }));
const res = await fetch('/api/admin/text-gen/chapter-names/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, chapters })
});
if (res.ok) {
chapNamesResult = 'applied';
chapNamesPreview = [];
await invalidateAll();
} else {
chapNamesResult = 'error';
}
} catch {
chapNamesResult = 'error';
} finally {
chapNamesApplying = false;
}
}
// ── Admin: audio TTS bulk enqueue ─────────────────────────────────────────
interface Voice { id: string; engine: string; lang: string; gender: string }
let audioVoices = $state<Voice[]>([]);
let audioVoicesLoaded = $state(false);
let audioVoice = $state('af_bella');
let audioFrom = $state('1');
let audioTo = $state('');
let audioEnqueuing = $state(false);
let audioResult = $state<{ enqueued: number; skipped: number } | null>(null);
let audioError = $state('');
// Load voices lazily when admin panel opens
$effect(() => {
if (!adminOpen || audioVoicesLoaded) return;
fetch('/api/voices')
.then((r) => r.json())
.then((d: { voices: Voice[] }) => {
audioVoices = d.voices ?? [];
audioVoicesLoaded = true;
})
.catch(() => { audioVoicesLoaded = true; });
});
function voiceLabel(v: Voice): string {
if (v.engine === 'cfai') {
const speaker = v.id.startsWith('cfai:') ? v.id.slice(5) : v.id;
return speaker.replace(/\b\w/g, (c) => c.toUpperCase()) + ' (CF AI)';
}
if (v.engine === 'pocket-tts') {
return v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + ' (Pocket)';
}
// Kokoro
const langMap: Record<string, string> = {
af: 'US', am: 'US', bf: 'UK', bm: 'UK',
ef: 'ES', em: 'ES', ff: 'FR', hf: 'IN', hm: 'IN',
'if': 'IT', im: 'IT', jf: 'JP', jm: 'JP', pf: 'PT', pm: 'PT', zf: 'ZH', zm: 'ZH',
};
const prefix = v.id.slice(0, 2);
const name = v.id.slice(3).replace(/^v0/, '').replace(/^([a-z])/, (c) => c.toUpperCase());
const lang = langMap[prefix] ?? prefix.toUpperCase();
return `${name} (${lang})`;
}
async function enqueueAudio() {
const slug = data.book?.slug;
if (audioEnqueuing || !slug) return;
const from = parseInt(audioFrom, 10);
const to = audioTo ? parseInt(audioTo, 10) : (data.book?.total_chapters ?? 1);
if (!from || from < 1) return;
audioEnqueuing = true;
audioResult = null;
audioError = '';
try {
const res = await fetch('/api/admin/audio/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, voice: audioVoice, from, to })
});
if (res.ok || res.status === 202) {
const d = await res.json();
audioResult = { enqueued: d.enqueued ?? 0, skipped: d.skipped ?? 0 };
} else {
const d = await res.json().catch(() => ({}));
audioError = d.error ?? 'Failed to enqueue';
}
} catch {
audioError = 'Network error';
} finally {
audioEnqueuing = false;
}
}
async function cancelAudio() {
const slug = data.book?.slug;
if (!slug) return;
audioResult = null;
audioError = '';
try {
const res = await fetch('/api/admin/audio/cancel-bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug })
});
if (res.ok) {
const d = await res.json();
audioError = `Cancelled ${d.cancelled ?? 0} task(s).`;
}
} catch {
audioError = 'Cancel failed';
}
}
// ── "More like this" ─────────────────────────────────────────────────────
interface SimilarBook { slug: string; title: string; cover: string | null; author: string | null }
let similarBooks = $state<SimilarBook[]>([]);
$effect(() => {
const firstGenre = genres[0];
if (!firstGenre) return;
const currentSlug = data.book?.slug ?? '';
fetch(`/api/catalogue-page?genre=${encodeURIComponent(firstGenre)}&sort=popular&page=1`)
.then((r) => r.json())
.then((d: { novels?: { slug: string; title: string; cover: string | null; author: string | null }[] }) => {
similarBooks = (d.novels ?? [])
.filter((b) => b.slug !== currentSlug)
.slice(0, 10);
})
.catch(() => { /* non-critical */ });
});
// ── Auto-poll when scrape task is in flight ───────────────────────────────
$effect(() => {
if (!data.scraping || !data.taskId) return;
@@ -234,7 +547,10 @@
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-3) text-(--color-text) border border-(--color-border)">{book.status}</span>
{/if}
{#each genres as genre}
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border)">{genre}</span>
<a
href="/catalogue?genre={encodeURIComponent(genre)}"
class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) hover:border-(--color-brand)/50 hover:text-(--color-text) transition-colors"
>{genre}</a>
{/each}
{#if data.readersThisWeek && data.readersThisWeek > 0}
<span class="text-xs px-2 py-0.5 rounded bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) flex items-center gap-1">
@@ -348,65 +664,82 @@
</div>
<!-- CTA buttons — mobile only -->
<div class="flex sm:hidden gap-2 items-center">
{#if data.lastChapter}
<a
href="/books/{book.slug}/chapters/{data.lastChapter}"
class="flex-1 text-center px-4 py-2.5 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg text-sm hover:bg-(--color-brand-dim) transition-colors shadow"
>
{m.book_detail_continue_ch({ n: String(data.lastChapter) })}
</a>
{/if}
{#if chapterList.length > 0}
<a
href="/books/{book.slug}/chapters/1"
class="flex-1 text-center px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors
{data.lastChapter
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
>
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
</a>
{/if}
{#if !data.isLoggedIn}
<a
href="/login"
title={m.book_detail_signin_to_save()}
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
</a>
{:else if data.inLib}
<button
onclick={toggleSave}
disabled={saving}
title={saved ? m.book_detail_remove_from_library() : m.book_detail_add_to_library()}
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border transition-colors disabled:opacity-50
{saved
? 'bg-(--color-brand)/20 text-(--color-brand-dim) border-(--color-brand)/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
: 'bg-(--color-surface-3) text-(--color-muted) border-(--color-border) hover:bg-(--color-surface-3) hover:text-(--color-text)'}"
>
{#if saving}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{:else if saved}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{:else}
<div class="flex sm:hidden flex-col gap-2 mt-3">
<!-- Row 1: primary read button(s) -->
<div class="flex gap-2">
{#if data.lastChapter}
<a
href="/books/{book.slug}/chapters/{data.lastChapter}"
class="flex-1 text-center px-4 py-2.5 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg text-sm hover:bg-(--color-brand-dim) transition-colors shadow"
>
{m.book_detail_continue_ch({ n: String(data.lastChapter) })}
</a>
{/if}
{#if chapterList.length > 0}
<a
href="/books/{book.slug}/chapters/1"
class="text-center px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors
{data.lastChapter
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
: 'flex-1 bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
>
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
</a>
{/if}
</div>
<!-- Row 2: bookmark + shelf + stars -->
<div class="flex items-center gap-2 flex-wrap">
{#if !data.isLoggedIn}
<a
href="/login"
title={m.book_detail_signin_to_save()}
class="flex items-center justify-center w-9 h-9 rounded-lg border border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors flex-shrink-0"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{/if}
</button>
{/if}
</a>
{:else if data.inLib}
<button
onclick={toggleSave}
disabled={saving}
title={saved ? m.book_detail_remove_from_library() : m.book_detail_add_to_library()}
class="flex items-center justify-center w-9 h-9 flex-shrink-0 rounded-lg border transition-colors disabled:opacity-50
{saved
? 'bg-(--color-brand)/20 text-(--color-brand-dim) border-(--color-brand)/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
: 'bg-(--color-surface-3) text-(--color-muted) border-(--color-border) hover:bg-(--color-surface-3) hover:text-(--color-text)'}"
>
{#if saving}
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{:else if saved}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{/if}
</button>
{/if}
{#if saved}
<select
value={currentShelf}
onchange={(e) => setShelf((e.currentTarget as HTMLSelectElement).value as ShelfName)}
class="bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-sm text-(--color-muted) focus:outline-none focus:ring-2 focus:ring-(--color-brand) cursor-pointer flex-shrink-0"
>
<option value="">Reading</option>
<option value="plan_to_read">Plan to Read</option>
<option value="completed">Completed</option>
<option value="dropped">Dropped</option>
</select>
{/if}
<!-- Ratings + shelf — mobile -->
<div class="flex sm:hidden items-center gap-3 flex-wrap mt-1">
<StarRating
rating={userRating}
avg={ratingAvg.avg}
@@ -414,20 +747,6 @@
onrate={rate}
size="sm"
/>
{#if saved}
<div class="relative">
<select
value={currentShelf}
onchange={(e) => setShelf((e.currentTarget as HTMLSelectElement).value as ShelfName)}
class="bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-sm text-(--color-muted) focus:outline-none focus:ring-2 focus:ring-(--color-brand) cursor-pointer"
>
<option value="">Reading</option>
<option value="plan_to_read">Plan to Read</option>
<option value="completed">Completed</option>
<option value="dropped">Dropped</option>
</select>
</div>
{/if}
</div>
</div>
</div>
@@ -496,78 +815,336 @@
</svg>
</button>
{#if adminOpen}
<div class="px-4 py-3 border-t border-(--color-border) flex flex-col gap-4">
<!-- Rescrape -->
{#if adminOpen}
<div class="px-4 py-3 border-t border-(--color-border) flex flex-col gap-5">
<!-- Rescrape -->
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={rescrape}
disabled={scraping}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
{scraping ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'}"
>
{#if scraping}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{m.book_detail_rescraping()}
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
{m.book_detail_rescrape_book()}
{/if}
</button>
{#if scrapeResult}
<span class="text-xs {scrapeResult === 'queued' ? 'text-green-400' : scrapeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
{scrapeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : scrapeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
</span>
{/if}
</div>
<!-- Range scrape -->
<div class="flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1">
<label for="range-from" class="text-xs text-(--color-muted)">{m.book_detail_from_chapter()}</label>
<input
id="range-from"
type="number"
min="1"
bind:value={rangeFrom}
placeholder="1"
class="w-24 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
</div>
<div class="flex flex-col gap-1">
<label for="range-to" class="text-xs text-(--color-muted)">{m.book_detail_to_chapter()}</label>
<input
id="range-to"
type="number"
min="1"
bind:value={rangeTo}
placeholder="end"
class="w-24 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
</div>
<button
onclick={scrapeRange}
disabled={rangeScraping || !rangeFrom}
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
{rangeScraping || !rangeFrom
? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed'
: 'bg-(--color-brand)/20 text-(--color-brand-dim) hover:bg-(--color-brand)/40 border border-(--color-brand)/30'}"
>
{rangeScraping ? m.book_detail_range_queuing() : m.book_detail_scrape_range()}
</button>
{#if rangeResult}
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
{rangeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : rangeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
</span>
{/if}
</div>
<hr class="border-(--color-border)" />
<!-- Book cover generation -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_book_cover()}</p>
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={rescrape}
disabled={scraping}
onclick={generateCover}
disabled={coverGenerating}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
{scraping ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'}"
{coverGenerating ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-2)'}"
>
{#if scraping}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{m.book_detail_rescraping()}
{#if coverGenerating}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
{m.book_detail_rescrape_book()}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 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>
{/if}
{m.book_detail_admin_generate()}
</button>
{#if scrapeResult}
<span class="text-xs {scrapeResult === 'queued' ? 'text-green-400' : scrapeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
{scrapeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : scrapeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
</span>
{#if coverResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
{:else if coverResult === 'saved'}
<span class="text-xs text-green-400">{m.book_detail_admin_saved()}</span>
{/if}
</div>
<!-- Range scrape -->
<div class="flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1">
<label for="range-from" class="text-xs text-(--color-muted)">{m.book_detail_from_chapter()}</label>
<input
id="range-from"
type="number"
min="1"
bind:value={rangeFrom}
placeholder="1"
class="w-24 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
{#if coverPreview}
<div class="flex items-start gap-3 mt-1">
<img src={coverPreview} alt="Cover preview" class="w-24 rounded border border-(--color-border)" />
<div class="flex flex-col gap-2 pt-1">
<button
onclick={saveCover}
disabled={coverSaving}
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
{coverSaving ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-green-600/20 text-green-400 hover:bg-green-600/30 border border-green-600/30'}"
>
{coverSaving ? m.book_detail_admin_saving() : m.book_detail_admin_save_cover()}
</button>
<button onclick={() => (coverPreview = null)} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
</div>
</div>
{/if}
</div>
<!-- Chapter cover generation -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_chapter_cover()}</p>
<div class="flex items-end gap-3 flex-wrap">
<div class="flex flex-col gap-1">
<label for="range-to" class="text-xs text-(--color-muted)">{m.book_detail_to_chapter()}</label>
<label for="ch-cover-n" class="text-xs text-(--color-muted)">{m.book_detail_admin_chapter_n()}</label>
<input
id="range-to"
id="ch-cover-n"
type="number"
min="1"
bind:value={rangeTo}
placeholder="end"
class="w-24 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
bind:value={chapterCoverN}
placeholder="1"
class="w-20 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
</div>
<button
onclick={scrapeRange}
disabled={rangeScraping || !rangeFrom}
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
{rangeScraping || !rangeFrom
? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed'
: 'bg-(--color-brand)/20 text-(--color-brand-dim) hover:bg-(--color-brand)/40 border border-(--color-brand)/30'}"
onclick={generateChapterCover}
disabled={chapterCoverGenerating || !chapterCoverN}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
{chapterCoverGenerating || !chapterCoverN ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-2)'}"
>
{rangeScraping ? m.book_detail_range_queuing() : m.book_detail_scrape_range()}
{#if chapterCoverGenerating}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 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>
{/if}
{m.book_detail_admin_generate()}
</button>
{#if rangeResult}
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
{rangeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : rangeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
</span>
{#if chapterCoverResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
{/if}
</div>
{#if chapterCoverPreview}
<div class="flex items-start gap-3 mt-1">
<img src={chapterCoverPreview} alt="Chapter cover preview" class="w-24 rounded border border-(--color-border)" />
<button onclick={() => (chapterCoverPreview = null)} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors pt-1">{m.book_detail_admin_discard()}</button>
</div>
{/if}
</div>
<hr class="border-(--color-border)" />
<!-- Description generation -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_description()}</p>
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={generateDesc}
disabled={descGenerating}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
{descGenerating ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-2)'}"
>
{#if descGenerating}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></svg>
{/if}
{m.book_detail_admin_generate()}
</button>
{#if descResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
{:else if descResult === 'applied'}
<span class="text-xs text-green-400">{m.book_detail_admin_applied()}</span>
{/if}
</div>
{#if descPreview}
<div class="flex flex-col gap-2">
<textarea
bind:value={descPreview}
rows="5"
class="w-full px-2 py-1.5 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand) resize-y"
></textarea>
<div class="flex items-center gap-2">
<button
onclick={applyDesc}
disabled={descApplying}
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
{descApplying ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-green-600/20 text-green-400 hover:bg-green-600/30 border border-green-600/30'}"
>
{descApplying ? m.book_detail_admin_applying() : m.book_detail_admin_apply()}
</button>
<button onclick={() => (descPreview = '')} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
</div>
</div>
{/if}
</div>
<!-- Chapter names generation -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_chapter_names()}</p>
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={generateChapNames}
disabled={chapNamesGenerating}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
{chapNamesGenerating ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-2)'}"
>
{#if chapNamesGenerating}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h10"/></svg>
{/if}
{m.book_detail_admin_generate()}
</button>
{#if chapNamesResult === 'error'}
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
{:else if chapNamesResult === 'applied'}
<span class="text-xs text-green-400">{m.book_detail_admin_applied()} ({chapNamesPreview.length > 0 ? chapNamesPreview.length : ''})</span>
{/if}
</div>
{#if chapNamesPreview.length > 0}
<div class="flex flex-col gap-1.5 max-h-48 overflow-y-auto rounded border border-(--color-border) p-2 bg-(--color-surface-3)">
{#each chapNamesPreview as ch}
<div class="flex gap-2 text-xs">
<span class="text-(--color-muted) flex-shrink-0 w-6 text-right">{ch.number}.</span>
<span class="text-(--color-text) truncate">{ch.new_title}</span>
</div>
{/each}
</div>
<div class="flex items-center gap-2">
<button
onclick={applyChapNames}
disabled={chapNamesApplying}
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
{chapNamesApplying ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-green-600/20 text-green-400 hover:bg-green-600/30 border border-green-600/30'}"
>
{chapNamesApplying ? m.book_detail_admin_applying() : m.book_detail_admin_apply()} ({chapNamesPreview.length})
</button>
<button onclick={() => (chapNamesPreview = [])} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">{m.book_detail_admin_discard()}</button>
</div>
{/if}
</div>
<hr class="border-(--color-border)" />
<!-- Audio TTS bulk enqueue -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">{m.book_detail_admin_audio_tts()}</p>
<div class="flex flex-col gap-3">
<!-- Voice selector -->
<div class="flex flex-col gap-1">
<label for="audio-voice" class="text-xs text-(--color-muted)">{m.book_detail_admin_voice()}</label>
<select
id="audio-voice"
bind:value={audioVoice}
class="px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
>
{#if !audioVoicesLoaded}
<option value="af_bella">af_bella (loading…)</option>
{:else}
{#each audioVoices.filter(v => v.engine === 'kokoro') as v}
<option value={v.id}>{voiceLabel(v)}</option>
{/each}
{#each audioVoices.filter(v => v.engine === 'pocket-tts') as v}
<option value={v.id}>{voiceLabel(v)}</option>
{/each}
{#each audioVoices.filter(v => v.engine === 'cfai') as v}
<option value={v.id}>{voiceLabel(v)}</option>
{/each}
{/if}
</select>
</div>
<!-- Chapter range -->
<div class="flex items-end gap-3 flex-wrap">
<div class="flex flex-col gap-1">
<label for="audio-from" class="text-xs text-(--color-muted)">{m.book_detail_from_chapter()}</label>
<input
id="audio-from"
type="number"
min="1"
bind:value={audioFrom}
placeholder="1"
class="w-20 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
</div>
<div class="flex flex-col gap-1">
<label for="audio-to" class="text-xs text-(--color-muted)">{m.book_detail_to_chapter()}</label>
<input
id="audio-to"
type="number"
min="1"
bind:value={audioTo}
placeholder="end"
class="w-20 px-2 py-1 rounded bg-(--color-surface-3) border border-(--color-border) text-(--color-text) text-xs focus:outline-none focus:border-(--color-brand)"
/>
</div>
<button
onclick={enqueueAudio}
disabled={audioEnqueuing || !audioFrom}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
{audioEnqueuing || !audioFrom ? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed' : 'bg-(--color-brand)/20 text-(--color-brand-dim) hover:bg-(--color-brand)/40 border border-(--color-brand)/30'}"
>
{#if audioEnqueuing}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v6m0 0l-2-2m2 2l2-2M6.343 17.657a8 8 0 010-11.314"/></svg>
{/if}
{m.book_detail_admin_enqueue_audio()}
</button>
<button
onclick={cancelAudio}
class="px-3 py-1.5 rounded text-xs font-medium text-(--color-muted) hover:text-(--color-danger) transition-colors border border-(--color-border)"
>
{m.book_detail_admin_cancel_audio()}
</button>
</div>
{#if audioResult}
<span class="text-xs text-green-400">{m.book_detail_admin_enqueued({ enqueued: audioResult.enqueued, skipped: audioResult.skipped })}</span>
{/if}
{#if audioError}
<span class="text-xs text-(--color-muted)">{audioError}</span>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
@@ -575,4 +1152,35 @@
<!-- ══════════════════════════════════════════════════ Comments ══ -->
<CommentsSection slug={book.slug} isLoggedIn={data.isLoggedIn} currentUserId={data.currentUserId} />
<!-- ══════════════════════════════════════════════ More like this ══ -->
{#if similarBooks.length > 0}
<div class="mt-12">
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-3">More like this</h2>
<div class="flex gap-3 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-none">
{#each similarBooks as b}
<a
href="/books/{b.slug}"
class="flex-shrink-0 w-24 sm:w-28 group"
>
<div class="aspect-[2/3] rounded-lg overflow-hidden bg-(--color-surface-2) border border-(--color-border) group-hover:border-zinc-500 transition-colors mb-1.5">
{#if b.cover}
<img src={b.cover} alt={b.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
{:else}
<div class="w-full h-full flex items-center justify-center text-(--color-muted)">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
</div>
<p class="text-xs font-medium text-(--color-text) line-clamp-2 leading-snug">{b.title}</p>
{#if b.author}
<p class="text-xs text-(--color-muted) truncate mt-0.5">{b.author}</p>
{/if}
</a>
{/each}
</div>
</div>
{/if}
{/if}

View File

@@ -1,11 +1,13 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { onMount, untrack, getContext } from 'svelte';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
import CommentsSection from '$lib/components/CommentsSection.svelte';
import type { PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
import { audioStore } from '$lib/audio.svelte';
let { data }: { data: PageData } = $props();
@@ -14,7 +16,186 @@
let fetchError = $state('');
let audioProRequired = $state(false);
// Translation state
// ── Reader settings panel ────────────────────────────────────────────────
const settingsCtx = getContext<{ current: string; fontFamily: string; fontSize: number } | undefined>('theme');
let settingsPanelOpen = $state(false);
const READER_THEMES = [
{ id: 'amber', label: 'Amber', swatch: '#f59e0b' },
{ id: 'slate', label: 'Slate', swatch: '#818cf8' },
{ id: 'rose', label: 'Rose', swatch: '#fb7185' },
{ id: 'light', label: 'Light', swatch: '#d97706', light: true },
{ id: 'light-slate', label: 'L·Slate',swatch: '#4f46e5', light: true },
{ id: 'light-rose', label: 'L·Rose', swatch: '#e11d48', light: true },
] as const;
const READER_FONTS = [
{ id: 'system', label: 'System' },
{ id: 'serif', label: 'Serif' },
{ id: 'mono', label: 'Mono' },
] as const;
const READER_SIZES = [
{ value: 0.9, label: 'S' },
{ value: 1.0, label: 'M' },
{ value: 1.15, label: 'L' },
{ value: 1.3, label: 'XL'},
] as const;
// Mirror context values into local reactive state so the panel shows current values
let panelTheme = $state(settingsCtx?.current ?? 'amber');
let panelFont = $state(settingsCtx?.fontFamily ?? 'system');
let panelSize = $state(settingsCtx?.fontSize ?? 1.0);
function applyTheme(id: string) {
panelTheme = id;
if (settingsCtx) settingsCtx.current = id;
}
function applyFont(id: string) {
panelFont = id;
if (settingsCtx) settingsCtx.fontFamily = id;
}
function applySize(v: number) {
panelSize = v;
if (settingsCtx) settingsCtx.fontSize = v;
}
// ── Layout / reading-view prefs (localStorage) ──────────────────────────────
type ReadMode = 'scroll' | 'paginated';
type LineSpacing = 'compact' | 'normal' | 'relaxed';
type ReadWidth = 'narrow' | 'normal' | 'wide';
type ParaStyle = 'spaced' | 'indented';
type PlayerStyle = 'standard' | 'compact';
interface LayoutPrefs {
readMode: ReadMode;
lineSpacing: LineSpacing;
readWidth: ReadWidth;
paraStyle: ParaStyle;
focusMode: boolean;
playerStyle: PlayerStyle;
}
const LAYOUT_KEY = 'reader_layout_v1';
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
const READ_WIDTHS: Record<ReadWidth, string> = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' };
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard' };
function loadLayout(): LayoutPrefs {
if (!browser) return DEFAULT_LAYOUT;
try {
const raw = localStorage.getItem(LAYOUT_KEY);
if (raw) return { ...DEFAULT_LAYOUT, ...JSON.parse(raw) as Partial<LayoutPrefs> };
} catch { /* ignore */ }
return DEFAULT_LAYOUT;
}
let layout = $state<LayoutPrefs>(loadLayout());
function setLayout<K extends keyof LayoutPrefs>(key: K, value: LayoutPrefs[K]) {
layout = { ...layout, [key]: value };
if (browser) localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout));
}
// ── Listening settings helpers ───────────────────────────────────────────────
const SETTINGS_SLEEP_OPTIONS = [15, 30, 45, 60];
const sleepSettingsLabel = $derived(
audioStore.sleepAfterChapter
? 'End Ch.'
: audioStore.sleepUntil > Date.now()
? `${Math.ceil((audioStore.sleepUntil - Date.now()) / 60000)}m`
: 'Off'
);
function toggleSleepFromSettings() {
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = true;
} else if (audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = false;
audioStore.sleepUntil = Date.now() + SETTINGS_SLEEP_OPTIONS[0] * 60 * 1000;
} else {
const remaining = audioStore.sleepUntil - Date.now();
const currentMin = Math.round(remaining / 60000);
const idx = SETTINGS_SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
if (idx === -1 || idx === SETTINGS_SLEEP_OPTIONS.length - 1) {
audioStore.sleepUntil = 0;
} else {
audioStore.sleepUntil = Date.now() + SETTINGS_SLEEP_OPTIONS[idx + 1] * 60 * 1000;
}
}
}
// Apply reading CSS vars whenever layout changes
$effect(() => {
if (!browser) return;
document.documentElement.style.setProperty('--reading-line-height', String(LINE_HEIGHTS[layout.lineSpacing]));
document.documentElement.style.setProperty('--reading-max-width', READ_WIDTHS[layout.readWidth]);
});
// ── Scroll progress bar ──────────────────────────────────────────────────────
let scrollProgress = $state(0);
$effect(() => {
if (!browser || layout.readMode === 'paginated') { scrollProgress = 0; return; }
function onScroll() {
const el = document.documentElement;
const max = el.scrollHeight - el.clientHeight;
scrollProgress = max > 0 ? el.scrollTop / max : 0;
}
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
});
// ── Paginated mode ───────────────────────────────────────────────────────────
let pageIndex = $state(0);
let totalPages = $state(1);
let paginatedContainerEl = $state<HTMLDivElement | null>(null);
let paginatedContentEl = $state<HTMLDivElement | null>(null);
let containerH = $state(0);
$effect(() => {
if (layout.readMode !== 'paginated') { pageIndex = 0; totalPages = 1; return; }
// Re-run when html changes or container is bound
void html; void paginatedContainerEl; void paginatedContentEl;
requestAnimationFrame(() => {
if (!paginatedContainerEl || !paginatedContentEl) return;
const h = paginatedContainerEl.clientHeight;
if (h > 0) {
containerH = h;
totalPages = Math.max(1, Math.ceil(paginatedContentEl.scrollHeight / h));
pageIndex = Math.min(pageIndex, totalPages - 1);
}
});
});
// Reset page index when chapter changes
$effect(() => { void data.chapter.number; pageIndex = 0; });
function handlePaginatedClick(e: MouseEvent) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
if (e.clientX - rect.left > rect.width / 2) {
if (pageIndex < totalPages - 1) pageIndex++;
} else {
if (pageIndex > 0) pageIndex--;
}
}
// Keyboard nav for paginated mode
$effect(() => {
if (!browser) return;
function onKey(e: KeyboardEvent) {
if (layout.readMode !== 'paginated') return;
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
e.preventDefault();
if (pageIndex < totalPages - 1) pageIndex++;
} else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
e.preventDefault();
if (pageIndex > 0) pageIndex--;
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
// ── Translation state
const SUPPORTED_LANGS = [
{ code: 'ru', label: 'RU' },
{ code: 'id', label: 'ID' },
@@ -147,6 +328,11 @@
<title>{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}{data.book.title} — libnovel</title>
</svelte:head>
<!-- Reading progress bar (scroll mode) -->
{#if layout.readMode === 'scroll'}
<div class="reading-progress" style="width: {scrollProgress * 100}%"></div>
{/if}
<!-- Top nav -->
<div class="flex items-center justify-between mb-6 gap-4">
<a
@@ -193,8 +379,8 @@
{/if}
</div>
<!-- Language switcher (not shown for preview chapters) -->
{#if !data.isPreview}
<!-- Language switcher (not shown for preview chapters or focus mode) -->
{#if !data.isPreview && !layout.focusMode}
<div class="flex items-center gap-2 mb-6 flex-wrap">
<span class="text-(--color-muted) text-xs">Read in:</span>
@@ -250,8 +436,8 @@
</div>
{/if}
<!-- Audio player -->
{#if !data.isPreview}
<!-- Audio player (hidden in focus mode) -->
{#if !data.isPreview && !layout.focusMode}
{#if !page.data.user}
<!-- Unauthenticated: sign-in prompt -->
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-border) flex items-center justify-between gap-4">
@@ -289,6 +475,7 @@
nextChapter={data.next}
chapters={data.chapters}
voices={data.voices}
playerStyle={layout.playerStyle}
onProRequired={() => { audioProRequired = true; }}
/>
{/if}
@@ -311,13 +498,66 @@
<div class="text-(--color-muted) text-center py-16">
<p>{fetchError || m.reader_audio_error()}</p>
</div>
{:else if layout.readMode === 'paginated'}
<!-- ── Paginated reader ─────────────────────────────────────────────── -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
bind:this={paginatedContainerEl}
class="paginated-container mt-8"
style="height: {layout.focusMode ? 'calc(100svh - 8rem)' : 'calc(100svh - 26rem)'};"
onclick={handlePaginatedClick}
>
<div
bind:this={paginatedContentEl}
class="prose-chapter {layout.paraStyle === 'indented' ? 'para-indented' : ''}"
style="transform: translateY({containerH > 0 ? -(pageIndex * containerH) : 0}px);"
>
{@html html}
</div>
</div>
<!-- Page indicator + nav -->
<div class="flex items-center justify-between mt-4 select-none">
<button
type="button"
onclick={() => { if (pageIndex > 0) pageIndex--; }}
disabled={pageIndex === 0}
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) disabled:opacity-30 transition-colors"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Prev
</button>
<span class="text-sm text-(--color-muted) tabular-nums">
{pageIndex + 1} <span class="opacity-40">/</span> {totalPages}
</span>
<button
type="button"
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
disabled={pageIndex === totalPages - 1}
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-muted) hover:text-(--color-text) disabled:opacity-30 transition-colors"
>
Next
<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="M9 5l7 7-7 7"/>
</svg>
</button>
</div>
<!-- Tap hint -->
<p class="text-center text-xs text-(--color-muted)/40 mt-2">Tap left/right · Arrow keys · Space</p>
{:else}
<div class="prose-chapter mt-8">
<!-- ── Scroll reader ────────────────────────────────────────────────── -->
<div class="prose-chapter mt-8 {layout.paraStyle === 'indented' ? 'para-indented' : ''}">
{@html html}
</div>
{/if}
<!-- Bottom nav -->
<!-- Bottom nav + comments (hidden in paginated focus mode) -->
{#if !(layout.focusMode && layout.readMode === 'paginated')}
<div class="flex justify-between mt-12 pt-6 border-t border-(--color-border) gap-4">
{#if data.prev}
<a
@@ -339,7 +579,6 @@
{/if}
</div>
<!-- Chapter comments -->
<div class="mt-12">
<CommentsSection
slug={data.book.slug}
@@ -348,3 +587,256 @@
currentUserId={page.data.user?.id ?? ''}
/>
</div>
{/if}
<!-- ── Floating reader settings ─────────────────────────────────────────── -->
{#if settingsCtx}
<!-- Backdrop -->
{#if settingsPanelOpen}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-40"
onclick={() => (settingsPanelOpen = false)}
></div>
{/if}
<!-- Gear button -->
<button
onclick={() => (settingsPanelOpen = !settingsPanelOpen)}
aria-label="Reader settings"
class="fixed bottom-20 right-4 z-50 w-11 h-11 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex items-center justify-center shadow-lg"
>
<svg class="w-5 h-5 {settingsPanelOpen ? 'text-(--color-brand)' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</button>
<!-- Settings drawer -->
{#if settingsPanelOpen}
<div
class="fixed bottom-36 right-4 z-50 w-72 bg-(--color-surface-2) border border-(--color-border) rounded-xl shadow-2xl p-4 flex flex-col gap-4"
>
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Reader Settings</p>
<!-- Theme -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Theme</p>
<div class="flex flex-wrap gap-1.5">
{#each READER_THEMES as t}
<button
onclick={() => applyTheme(t.id)}
title={t.label}
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors
{panelTheme === t.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelTheme === t.id}
>
<span class="w-2.5 h-2.5 rounded-full shrink-0 {'light' in t && t.light ? 'ring-1 ring-(--color-border)' : ''}" style="background: {t.swatch};"></span>
{t.label}
</button>
{/each}
</div>
</div>
<!-- Font family -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Font</p>
<div class="flex gap-1.5">
{#each READER_FONTS as f}
<button
onclick={() => applyFont(f.id)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{panelFont === f.id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelFont === f.id}
>
{f.label}
</button>
{/each}
</div>
</div>
<!-- Text size -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Text size</p>
<div class="flex gap-1.5">
{#each READER_SIZES as s}
<button
onclick={() => applySize(s.value)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{panelSize === s.value
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={panelSize === s.value}
>
{s.label}
</button>
{/each}
</div>
</div>
<!-- Divider -->
<div class="border-t border-(--color-border)"></div>
<!-- Read mode -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Read mode</p>
<div class="flex gap-1.5">
{#each ([['scroll', 'Scroll'], ['paginated', 'Pages']] as const) as [mode, label]}
<button
type="button"
onclick={() => setLayout('readMode', mode)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.readMode === mode
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.readMode === mode}
>{label}</button>
{/each}
</div>
</div>
<!-- Line spacing -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Line spacing</p>
<div class="flex gap-1.5">
{#each ([['compact', 'Tight'], ['normal', 'Normal'], ['relaxed', 'Loose']] as const) as [s, label]}
<button
type="button"
onclick={() => setLayout('lineSpacing', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.lineSpacing === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.lineSpacing === s}
>{label}</button>
{/each}
</div>
</div>
<!-- Reading width -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Width</p>
<div class="flex gap-1.5">
{#each ([['narrow', 'Narrow'], ['normal', 'Normal'], ['wide', 'Wide']] as const) as [w, label]}
<button
type="button"
onclick={() => setLayout('readWidth', w)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.readWidth === w
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.readWidth === w}
>{label}</button>
{/each}
</div>
</div>
<!-- Paragraph style -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Paragraphs</p>
<div class="flex gap-1.5">
{#each ([['spaced', 'Spaced'], ['indented', 'Indented']] as const) as [s, label]}
<button
type="button"
onclick={() => setLayout('paraStyle', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.paraStyle === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.paraStyle === s}
>{label}</button>
{/each}
</div>
</div>
<!-- Focus mode -->
<button
type="button"
onclick={() => setLayout('focusMode', !layout.focusMode)}
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
{layout.focusMode
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.focusMode}
>
<span>Focus mode</span>
<span class="opacity-60 text-xs">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
</button>
<!-- ── Listening section ─────────────────────────────────────────── -->
<div class="border-t border-(--color-border)"></div>
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Listening</p>
<!-- Player style -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Player style</p>
<div class="flex gap-1.5">
{#each ([['standard', 'Standard'], ['compact', 'Compact']] as const) as [s, label]}
<button
type="button"
onclick={() => setLayout('playerStyle', s)}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{layout.playerStyle === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={layout.playerStyle === s}
>{label}</button>
{/each}
</div>
</div>
{#if page.data.user}
<!-- Playback speed -->
<div class="space-y-2">
<p class="text-xs text-(--color-muted)">Speed</p>
<div class="flex gap-1">
{#each [0.75, 1, 1.25, 1.5, 2] as s}
<button
type="button"
onclick={() => { audioStore.speed = s; }}
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
{audioStore.speed === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={audioStore.speed === s}
>{s}×</button>
{/each}
</div>
</div>
<!-- Auto-next -->
<button
type="button"
onclick={() => { audioStore.autoNext = !audioStore.autoNext; }}
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
{audioStore.autoNext
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
aria-pressed={audioStore.autoNext}
>
<span>Auto-next chapter</span>
<span class="opacity-60">{audioStore.autoNext ? 'On' : 'Off'}</span>
</button>
<!-- Sleep timer -->
<button
type="button"
onclick={toggleSleepFromSettings}
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
{audioStore.sleepUntil || audioStore.sleepAfterChapter
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
>
<span>Sleep timer</span>
<span class="opacity-60">{sleepSettingsLabel}</span>
</button>
{/if}
<p class="text-xs text-(--color-muted)/60 text-center">Changes save automatically</p>
</div>
{/if}
{/if}

View File

@@ -212,6 +212,21 @@
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
});
// ── Audio-available set ───────────────────────────────────────────────────
let audioSlugs = $state<Set<string>>(new Set());
let filterAudioOnly = $state(false);
$effect(() => {
fetch('/api/audio/slugs')
.then((r) => r.json())
.then((d: { slugs: string[] }) => { audioSlugs = new Set(d.slugs ?? []); })
.catch(() => { /* non-critical */ });
});
const displayedNovels = $derived(
filterAudioOnly ? novels.filter((n) => audioSlugs.has(n.slug)) : novels
);
</script>
<svelte:head>
@@ -331,6 +346,23 @@
</button>
</div>
<!-- Audio-only filter toggle -->
{#if audioSlugs.size > 0}
<button
onclick={() => (filterAudioOnly = !filterAudioOnly)}
title="Show only books with audio"
class="flex items-center gap-1.5 px-2.5 py-2 rounded border text-sm transition-colors shrink-0
{filterAudioOnly
? 'bg-(--color-brand)/15 border-(--color-brand)/50 text-(--color-brand)'
: 'bg-(--color-surface-2) border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500'}"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6a7 7 0 010 12M9 10.5a2.5 2.5 0 000 3" />
</svg>
<span class="hidden sm:inline text-xs font-medium">Audio</span>
</button>
{/if}
<!-- Admin: refresh catalogue -->
{#if data.isAdmin}
<form
@@ -458,9 +490,9 @@
{/if}
<!-- Content -->
{#if novels.length === 0}
{#if displayedNovels.length === 0}
<div class="text-center py-20 text-(--color-muted)">
<p class="text-lg">{isSearchView ? m.catalogue_no_results_search() : isRankView ? m.catalogue_rank_no_data() : m.catalogue_no_results()}</p>
<p class="text-lg">{isSearchView ? m.catalogue_no_results_search() : isRankView ? m.catalogue_rank_no_data() : filterAudioOnly ? 'No books with audio found.' : m.catalogue_no_results()}</p>
<p class="text-sm mt-2">
{#if isSearchView}
{m.catalogue_no_results_try()}
@@ -470,6 +502,8 @@
{:else}
{m.catalogue_rank_run_scrape_user()}
{/if}
{:else if filterAudioOnly}
<button onclick={() => (filterAudioOnly = false)} class="text-(--color-brand) hover:underline">Clear audio filter</button>
{:else}
{m.catalogue_no_results_filters()}
{/if}
@@ -479,7 +513,7 @@
{:else if view === 'grid'}
<!-- ── Grid view ─────────────────────────────────────────────────────── -->
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{#each novels as novel}
{#each displayedNovels as novel}
{@const isLoading = loadingSlug === novel.slug}
<a
href="/books/{novel.slug}"
@@ -514,6 +548,14 @@
{novel.rating}
</span>
{/if}
<!-- Audio badge -->
{#if audioSlugs.has(novel.slug)}
<span class="absolute bottom-1.5 left-1.5 flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded bg-(--color-brand)/80 text-(--color-surface) font-medium" title="Audio available">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6a7 7 0 010 12M9 10.5a2.5 2.5 0 000 3" />
</svg>
</span>
{/if}
<!-- Loading overlay -->
{#if isLoading}
<div class="absolute inset-0 bg-(--color-surface)/70 flex items-center justify-center">
@@ -564,7 +606,7 @@
{:else}
<!-- ── List view ─────────────────────────────────────────────────────── -->
<div class="flex flex-col gap-2">
{#each novels as novel}
{#each displayedNovels as novel}
{@const isLoading = loadingSlug === novel.slug}
<div
class="flex items-center gap-4 bg-(--color-surface-2) border rounded-lg px-4 py-3 transition-colors
@@ -623,6 +665,14 @@
{#if novel.rating}
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">{novel.rating}</span>
{/if}
{#if audioSlugs.has(novel.slug)}
<span class="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 font-medium">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6a7 7 0 010 12M9 10.5a2.5 2.5 0 000 3" />
</svg>
Audio
</span>
{/if}
{#if novel.genres?.length}
{#each novel.genres.slice(0, 3) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>

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