Compare commits

...

5 Commits

Author SHA1 Message Date
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
34 changed files with 2048 additions and 62 deletions

View File

@@ -99,12 +99,17 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
}
systemPrompt := `You are a chapter title editor for a web novel platform. ` +
`The user will provide a list of chapter numbers with their current titles, ` +
`and a naming pattern. Your task is to produce a renamed version of every chapter ` +
`following the pattern exactly. ` +
`Respond ONLY with a JSON array — no prose, no markdown fences, no explanation. ` +
`Each element must be an object: {"number": <int>, "title": <string>}. ` +
`Output every chapter in the input list. Do not skip any.`
`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",
@@ -117,8 +122,14 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
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)
"slug", req.Slug, "chapters", len(chapters), "model", model, "max_tokens", maxTokens)
raw, genErr := s.deps.TextGen.Generate(r.Context(), cfai.TextRequest{
Model: model,
@@ -126,7 +137,7 @@ func (s *Server) handleAdminTextGenChapterNames(w http.ResponseWriter, r *http.R
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPrompt},
},
MaxTokens: req.MaxTokens,
MaxTokens: maxTokens,
})
if genErr != nil {
s.deps.Log.Error("admin: text-gen chapter-names failed", "err", genErr)

View File

@@ -302,6 +302,24 @@
"book_detail_range_queuing": "Queuing\u2026",
"book_detail_scrape_range": "Scrape range",
"book_detail_admin": "Admin",
"book_detail_admin_book_cover": "Book Cover",
"book_detail_admin_chapter_cover": "Chapter Cover",
"book_detail_admin_chapter_n": "Chapter #",
"book_detail_admin_description": "Description",
"book_detail_admin_chapter_names": "Chapter Names",
"book_detail_admin_audio_tts": "Audio TTS",
"book_detail_admin_voice": "Voice",
"book_detail_admin_generate": "Generate",
"book_detail_admin_save_cover": "Save Cover",
"book_detail_admin_saving": "Saving…",
"book_detail_admin_saved": "Saved",
"book_detail_admin_apply": "Apply",
"book_detail_admin_applying": "Applying…",
"book_detail_admin_applied": "Applied",
"book_detail_admin_discard": "Discard",
"book_detail_admin_enqueue_audio": "Enqueue Audio",
"book_detail_admin_cancel_audio": "Cancel",
"book_detail_admin_enqueued": "Enqueued {enqueued}, skipped {skipped}",
"book_detail_scraping_progress": "Fetching the first 20 chapters. This page will refresh automatically.",
"book_detail_scraping_home": "\u2190 Home",
"book_detail_rescrape_book": "Rescrape book",

View File

@@ -302,6 +302,24 @@
"book_detail_range_queuing": "En file d'attente…",
"book_detail_scrape_range": "Plage d'extraction",
"book_detail_admin": "Admin",
"book_detail_admin_book_cover": "Couverture du livre",
"book_detail_admin_chapter_cover": "Couverture du chapitre",
"book_detail_admin_chapter_n": "Chapitre n°",
"book_detail_admin_description": "Description",
"book_detail_admin_chapter_names": "Noms des chapitres",
"book_detail_admin_audio_tts": "Audio TTS",
"book_detail_admin_voice": "Voix",
"book_detail_admin_generate": "Générer",
"book_detail_admin_save_cover": "Enregistrer la couverture",
"book_detail_admin_saving": "Enregistrement…",
"book_detail_admin_saved": "Enregistré",
"book_detail_admin_apply": "Appliquer",
"book_detail_admin_applying": "Application…",
"book_detail_admin_applied": "Appliqué",
"book_detail_admin_discard": "Ignorer",
"book_detail_admin_enqueue_audio": "Mettre en file audio",
"book_detail_admin_cancel_audio": "Annuler",
"book_detail_admin_enqueued": "{enqueued} en file, {skipped} ignorés",
"book_detail_scraping_progress": "Récupération des 20 premiers chapitres. Cette page sera actualisée automatiquement.",
"book_detail_scraping_home": "← Accueil",
"book_detail_rescrape_book": "Réextraire le livre",

View File

@@ -302,6 +302,24 @@
"book_detail_range_queuing": "Mengantri…",
"book_detail_scrape_range": "Rentang scrape",
"book_detail_admin": "Admin",
"book_detail_admin_book_cover": "Sampul Buku",
"book_detail_admin_chapter_cover": "Sampul Bab",
"book_detail_admin_chapter_n": "Bab #",
"book_detail_admin_description": "Deskripsi",
"book_detail_admin_chapter_names": "Nama Bab",
"book_detail_admin_audio_tts": "Audio TTS",
"book_detail_admin_voice": "Suara",
"book_detail_admin_generate": "Buat",
"book_detail_admin_save_cover": "Simpan Sampul",
"book_detail_admin_saving": "Menyimpan…",
"book_detail_admin_saved": "Tersimpan",
"book_detail_admin_apply": "Terapkan",
"book_detail_admin_applying": "Menerapkan…",
"book_detail_admin_applied": "Diterapkan",
"book_detail_admin_discard": "Buang",
"book_detail_admin_enqueue_audio": "Antre Audio",
"book_detail_admin_cancel_audio": "Batal",
"book_detail_admin_enqueued": "Diantre {enqueued}, dilewati {skipped}",
"book_detail_scraping_progress": "Mengambil 20 bab pertama. Halaman ini akan dimuat ulang otomatis.",
"book_detail_scraping_home": "← Beranda",
"book_detail_rescrape_book": "Scrape ulang buku",

View File

@@ -302,6 +302,24 @@
"book_detail_range_queuing": "Na fila…",
"book_detail_scrape_range": "Intervalo de extração",
"book_detail_admin": "Admin",
"book_detail_admin_book_cover": "Capa do Livro",
"book_detail_admin_chapter_cover": "Capa do Capítulo",
"book_detail_admin_chapter_n": "Capítulo nº",
"book_detail_admin_description": "Descrição",
"book_detail_admin_chapter_names": "Nomes dos Capítulos",
"book_detail_admin_audio_tts": "Áudio TTS",
"book_detail_admin_voice": "Voz",
"book_detail_admin_generate": "Gerar",
"book_detail_admin_save_cover": "Salvar Capa",
"book_detail_admin_saving": "Salvando…",
"book_detail_admin_saved": "Salvo",
"book_detail_admin_apply": "Aplicar",
"book_detail_admin_applying": "Aplicando…",
"book_detail_admin_applied": "Aplicado",
"book_detail_admin_discard": "Descartar",
"book_detail_admin_enqueue_audio": "Enfileirar Áudio",
"book_detail_admin_cancel_audio": "Cancelar",
"book_detail_admin_enqueued": "{enqueued} enfileirados, {skipped} ignorados",
"book_detail_scraping_progress": "Buscando os primeiros 20 capítulos. Esta página será atualizada automaticamente.",
"book_detail_scraping_home": "← Início",
"book_detail_rescrape_book": "Reextrair livro",

View File

@@ -302,6 +302,24 @@
"book_detail_range_queuing": "В очереди…",
"book_detail_scrape_range": "Диапазон глав",
"book_detail_admin": "Администрирование",
"book_detail_admin_book_cover": "Обложка книги",
"book_detail_admin_chapter_cover": "Обложка главы",
"book_detail_admin_chapter_n": "Глава №",
"book_detail_admin_description": "Описание",
"book_detail_admin_chapter_names": "Названия глав",
"book_detail_admin_audio_tts": "Аудио TTS",
"book_detail_admin_voice": "Голос",
"book_detail_admin_generate": "Сгенерировать",
"book_detail_admin_save_cover": "Сохранить обложку",
"book_detail_admin_saving": "Сохранение…",
"book_detail_admin_saved": "Сохранено",
"book_detail_admin_apply": "Применить",
"book_detail_admin_applying": "Применение…",
"book_detail_admin_applied": "Применено",
"book_detail_admin_discard": "Отменить",
"book_detail_admin_enqueue_audio": "Поставить в очередь",
"book_detail_admin_cancel_audio": "Отмена",
"book_detail_admin_enqueued": "В очереди {enqueued}, пропущено {skipped}",
"book_detail_scraping_progress": "Загружаются первые 20 глав. Страница обновится автоматически.",
"book_detail_scraping_home": "← На главную",
"book_detail_rescrape_book": "Перепарсить книгу",

View File

@@ -69,6 +69,8 @@
voices?: Voice[];
/** Called when the server returns 402 (free daily limit reached). */
onProRequired?: () => void;
/** Visual style of the player card. 'standard' = full controls; 'compact' = slim seekable player. */
playerStyle?: 'standard' | 'compact';
}
let {
@@ -80,7 +82,8 @@
nextChapter = null,
chapters = [],
voices = [],
onProRequired = undefined
onProRequired = undefined,
playerStyle = 'standard'
}: Props = $props();
// ── Derived: voices grouped by engine ──────────────────────────────────
@@ -564,7 +567,35 @@
return;
}
// Slow path: trigger Kokoro generation (non-blocking POST), then poll.
// Slow path: audio not yet in MinIO.
//
// For Kokoro / PocketTTS when presign has NOT already enqueued the runner:
// use the streaming endpoint — audio starts playing within seconds while
// generation runs and MinIO is populated concurrently.
// Skip when enqueued=true to avoid double-generation with the async runner.
if (!voice.startsWith('cfai:') && !presignResult.enqueued) {
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
// WAV (PCM) is natively supported on all platforms including iOS Safari.
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts');
const format = isPocketTTS ? 'wav' : 'mp3';
const qs = new URLSearchParams({ voice, format });
const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`;
// HEAD probe: check paywall without triggering generation.
const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null);
if (headRes?.status === 402) {
audioStore.status = 'idle';
onProRequired?.();
return;
}
audioStore.audioUrl = streamUrl;
audioStore.status = 'ready';
maybeStartPrefetch();
return;
}
// CF AI (batch-only) or already enqueued by presign: keep the traditional
// POST → poll → presign flow. For enqueued, we skip the POST and poll.
audioStore.status = 'generating';
startProgress();
@@ -738,6 +769,24 @@
if (m > 0) return `${m}m`;
return `${s}s`;
}
// ── Compact player helpers ─────────────────────────────────────────────────
const playPct = $derived(
audioStore.duration > 0 ? (audioStore.currentTime / audioStore.duration) * 100 : 0
);
const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const;
function cycleSpeed() {
const idx = SPEED_OPTIONS.indexOf(audioStore.speed as (typeof SPEED_OPTIONS)[number]);
audioStore.speed = SPEED_OPTIONS[(idx + 1) % SPEED_OPTIONS.length];
}
function seekFromCompactBar(e: MouseEvent) {
if (audioStore.duration <= 0) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
audioStore.seekRequest = pct * audioStore.duration;
}
</script>
<svelte:window onkeydown={handleKeyDown} />
@@ -788,6 +837,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">
@@ -1026,3 +1190,4 @@
</Button>
{/if}
</div>
{/if}

View File

@@ -1,2 +1,4 @@
/* eslint-disable */
export * from './messages/_index.js'
export * from './messages/_index.js'
// enabling auto-import by exposing all messages as m
export * as m from './messages/_index.js'

View File

@@ -279,6 +279,24 @@ export * from './book_detail_to_chapter.js'
export * from './book_detail_range_queuing.js'
export * from './book_detail_scrape_range.js'
export * from './book_detail_admin.js'
export * from './book_detail_admin_book_cover.js'
export * from './book_detail_admin_chapter_cover.js'
export * from './book_detail_admin_chapter_n.js'
export * from './book_detail_admin_description.js'
export * from './book_detail_admin_chapter_names.js'
export * from './book_detail_admin_audio_tts.js'
export * from './book_detail_admin_voice.js'
export * from './book_detail_admin_generate.js'
export * from './book_detail_admin_save_cover.js'
export * from './book_detail_admin_saving.js'
export * from './book_detail_admin_saved.js'
export * from './book_detail_admin_apply.js'
export * from './book_detail_admin_applying.js'
export * from './book_detail_admin_applied.js'
export * from './book_detail_admin_discard.js'
export * from './book_detail_admin_enqueue_audio.js'
export * from './book_detail_admin_cancel_audio.js'
export * from './book_detail_admin_enqueued.js'
export * from './book_detail_scraping_progress.js'
export * from './book_detail_scraping_home.js'
export * from './book_detail_rescrape_book.js'

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

@@ -192,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() {

View File

@@ -71,6 +71,11 @@
);
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 {
@@ -287,6 +292,9 @@
{#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>

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

@@ -133,6 +133,301 @@
// ── 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[]>([]);
@@ -520,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>

View File

@@ -7,6 +7,7 @@
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();
@@ -62,6 +63,7 @@
type LineSpacing = 'compact' | 'normal' | 'relaxed';
type ReadWidth = 'narrow' | 'normal' | 'wide';
type ParaStyle = 'spaced' | 'indented';
type PlayerStyle = 'standard' | 'compact';
interface LayoutPrefs {
readMode: ReadMode;
@@ -69,12 +71,13 @@
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 };
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard' };
function loadLayout(): LayoutPrefs {
if (!browser) return DEFAULT_LAYOUT;
@@ -92,6 +95,34 @@
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;
@@ -444,6 +475,7 @@
nextChapter={data.next}
chapters={data.chapters}
voices={data.voices}
playerStyle={layout.playerStyle}
onProRequired={() => { audioProRequired = true; }}
/>
{/if}
@@ -735,6 +767,75 @@
<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}