Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1abb4cd714 | ||
|
|
a308672317 | ||
|
|
5d7c3b42fa | ||
|
|
45f5c51da6 |
@@ -239,6 +239,11 @@ jobs:
|
||||
name: ui-build-injected
|
||||
path: ui/build
|
||||
|
||||
- name: Allow build/ into Docker context (override .dockerignore)
|
||||
run: |
|
||||
grep -v '^build$' ui/.dockerignore > ui/.dockerignore.tmp
|
||||
mv ui/.dockerignore.tmp ui/.dockerignore
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
|
||||
@@ -904,6 +904,115 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
// on its next poll as soon as the MinIO object is present.
|
||||
}
|
||||
|
||||
// handleAudioPreview handles GET /api/audio-preview/{slug}/{n}.
|
||||
//
|
||||
// CF AI voices are batch-only and can take 1-2+ minutes to generate a full
|
||||
// chapter. This endpoint generates only the FIRST chunk of text (~1 800 chars,
|
||||
// roughly 1-2 minutes of audio) so the client can start playing immediately
|
||||
// while the full audio is generated in the background by the runner.
|
||||
//
|
||||
// Fast path: if a preview object already exists in MinIO, redirects to its
|
||||
// presigned URL (no regeneration).
|
||||
//
|
||||
// Slow path: generates the first chunk via CF AI, streams the MP3 bytes to the
|
||||
// client, and simultaneously uploads to MinIO under a "_preview" key so future
|
||||
// requests hit the fast path.
|
||||
//
|
||||
// Only CF AI voices are expected here. Calling this with a Kokoro/PocketTTS
|
||||
// voice falls back to the normal audio-stream endpoint behaviour.
|
||||
//
|
||||
// Query params:
|
||||
//
|
||||
// voice (required — must be a cfai: voice)
|
||||
func (s *Server) handleAudioPreview(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
n, err := strconv.Atoi(r.PathValue("n"))
|
||||
if err != nil || n < 1 {
|
||||
jsonError(w, http.StatusBadRequest, "invalid chapter")
|
||||
return
|
||||
}
|
||||
|
||||
voice := r.URL.Query().Get("voice")
|
||||
if voice == "" {
|
||||
voice = s.cfg.DefaultVoice
|
||||
}
|
||||
|
||||
if s.deps.CFAI == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
|
||||
return
|
||||
}
|
||||
|
||||
// Preview key: same as normal key with a "_preview" suffix before the extension.
|
||||
// e.g. slug/1/cfai:luna_preview.mp3
|
||||
previewKey := s.deps.AudioStore.AudioObjectKeyExt(slug, n, voice+"_preview", "mp3")
|
||||
|
||||
// ── Fast path: preview already in MinIO ──────────────────────────────────
|
||||
if s.deps.AudioStore.AudioExists(r.Context(), previewKey) {
|
||||
presignURL, err := s.deps.PresignStore.PresignAudio(r.Context(), previewKey, 1*time.Hour)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAudioPreview: PresignAudio failed", "slug", slug, "n", n, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "presign failed")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, presignURL, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
// ── Slow path: generate first chunk + stream + save ──────────────────────
|
||||
|
||||
// Read the chapter text.
|
||||
raw, err := s.deps.BookReader.ReadChapter(r.Context(), slug, n)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAudioPreview: ReadChapter failed", "slug", slug, "n", n, "err", err)
|
||||
jsonError(w, http.StatusNotFound, "chapter not found")
|
||||
return
|
||||
}
|
||||
text := stripMarkdown(raw)
|
||||
if text == "" {
|
||||
jsonError(w, http.StatusUnprocessableEntity, "chapter text is empty")
|
||||
return
|
||||
}
|
||||
|
||||
// Take only the first ~1 800 characters — one CF AI chunk, roughly 1-2 min.
|
||||
const previewChars = 1800
|
||||
firstChunk := text
|
||||
if len([]rune(text)) > previewChars {
|
||||
runes := []rune(text)
|
||||
firstChunk = string(runes[:previewChars])
|
||||
// Walk back to last sentence boundary (. ! ?) to avoid a mid-word cut.
|
||||
for i := previewChars - 1; i > previewChars/2; i-- {
|
||||
r := runes[i]
|
||||
if r == '.' || r == '!' || r == '?' || r == '\n' {
|
||||
firstChunk = string(runes[:i+1])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the preview chunk via CF AI.
|
||||
mp3, err := s.deps.CFAI.GenerateAudio(r.Context(), firstChunk, voice)
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleAudioPreview: GenerateAudio failed", "slug", slug, "n", n, "voice", voice, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "tts generation failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Upload to MinIO in the background so the next request hits the fast path.
|
||||
go func() {
|
||||
if uploadErr := s.deps.AudioStore.PutAudio(
|
||||
context.Background(), previewKey, mp3,
|
||||
); uploadErr != nil {
|
||||
s.deps.Log.Error("handleAudioPreview: MinIO upload failed", "key", previewKey, "err", uploadErr)
|
||||
}
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "audio/mpeg")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(mp3)))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(mp3)
|
||||
}
|
||||
|
||||
// ── Translation ────────────────────────────────────────────────────────────────
|
||||
|
||||
// supportedTranslationLangs is the set of target locales the backend accepts.
|
||||
|
||||
@@ -180,6 +180,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
// Streaming audio: serves from MinIO if cached, else streams live TTS
|
||||
// while simultaneously uploading to MinIO for future requests.
|
||||
mux.HandleFunc("GET /api/audio-stream/{slug}/{n}", s.handleAudioStream)
|
||||
// CF AI preview: generates only the first ~1 800-char chunk so the client
|
||||
// can start playing immediately while the full audio is generated by the runner.
|
||||
mux.HandleFunc("GET /api/audio-preview/{slug}/{n}", s.handleAudioPreview)
|
||||
|
||||
// Translation task creation (backend creates task; runner executes via LibreTranslate)
|
||||
mux.HandleFunc("POST /api/translation/{slug}/{n}", s.handleTranslationGenerate)
|
||||
|
||||
@@ -62,6 +62,13 @@ class AudioStore {
|
||||
/** Pseudo-progress bar value 0–100 during generation */
|
||||
progress = $state(0);
|
||||
|
||||
/**
|
||||
* True while playing a short CF AI preview clip (~1-2 min) and the full
|
||||
* audio is still being generated in the background. Set to false once the
|
||||
* full audio URL has been swapped in.
|
||||
*/
|
||||
isPreview = $state(false);
|
||||
|
||||
// ── Playback state (kept in sync with the <audio> element) ─────────────
|
||||
currentTime = $state(0);
|
||||
duration = $state(0);
|
||||
|
||||
@@ -603,13 +603,17 @@
|
||||
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.
|
||||
// CF AI voices: use preview/swap strategy.
|
||||
// 1. Fetch a short ~1-2 min preview clip from the first text chunk
|
||||
// so playback starts immediately — no more waiting behind a spinner.
|
||||
// 2. Meanwhile keep polling the full audio job; when it finishes,
|
||||
// swap the <audio> src to the full URL preserving currentTime.
|
||||
audioStore.status = 'generating';
|
||||
audioStore.isPreview = false;
|
||||
startProgress();
|
||||
|
||||
// presignResult.enqueued=true means /api/presign/audio already POSTed on our
|
||||
// behalf — skip the duplicate POST and go straight to polling.
|
||||
// Kick off the full audio generation task in the background
|
||||
// (presignResult.enqueued=true means the presign endpoint already did it).
|
||||
if (!presignResult.enqueued) {
|
||||
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
|
||||
method: 'POST',
|
||||
@@ -618,7 +622,6 @@
|
||||
});
|
||||
|
||||
if (res.status === 402) {
|
||||
// Free daily limit reached — surface upgrade CTA
|
||||
audioStore.status = 'idle';
|
||||
stopProgress();
|
||||
onProRequired?.();
|
||||
@@ -628,37 +631,96 @@
|
||||
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
|
||||
|
||||
if (res.status === 200) {
|
||||
// Already cached — body is { status: 'done' }, no url needed.
|
||||
// Already cached — fast path: presign and play directly.
|
||||
await res.body?.cancel();
|
||||
await finishProgress();
|
||||
const doneUrl = await tryPresign(slug, chapter, voice);
|
||||
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
|
||||
audioStore.isPreview = false;
|
||||
audioStore.audioUrl = doneUrl.url;
|
||||
audioStore.status = 'ready';
|
||||
maybeStartPrefetch();
|
||||
return;
|
||||
}
|
||||
// 202: fall through to polling below.
|
||||
// 202 accepted — fall through: start preview while runner generates
|
||||
}
|
||||
|
||||
// Poll until the runner finishes generating.
|
||||
const final = await pollAudioStatus(slug, chapter, voice);
|
||||
if (final.status === 'failed') {
|
||||
throw new Error(
|
||||
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
|
||||
);
|
||||
// Fetch the preview clip (first ~1-2 min chunk).
|
||||
// Use an AbortController so we can cancel the background polling if the
|
||||
// user navigates away or stops playback before the full audio is ready.
|
||||
const previewAbort = new AbortController();
|
||||
const qs = new URLSearchParams({ voice });
|
||||
const previewUrl = `/api/audio-preview/${slug}/${chapter}?${qs}`;
|
||||
|
||||
try {
|
||||
const previewRes = await fetch(previewUrl, { signal: previewAbort.signal });
|
||||
if (previewRes.status === 402) {
|
||||
audioStore.status = 'idle';
|
||||
stopProgress();
|
||||
onProRequired?.();
|
||||
return;
|
||||
}
|
||||
if (!previewRes.ok) throw new Error(`Preview failed: HTTP ${previewRes.status}`);
|
||||
|
||||
// The backend responded with the MP3 bytes (or a redirect to MinIO).
|
||||
// Build a blob URL so we can swap it out later without reloading the page.
|
||||
const previewBlob = await previewRes.blob();
|
||||
const previewBlobUrl = URL.createObjectURL(previewBlob);
|
||||
|
||||
audioStore.isPreview = true;
|
||||
audioStore.audioUrl = previewBlobUrl;
|
||||
audioStore.status = 'ready';
|
||||
// Don't restore saved time here — preview is always from 0.
|
||||
// Kick off prefetch of next chapter in the background.
|
||||
maybeStartPrefetch();
|
||||
} catch (previewErr: unknown) {
|
||||
if (previewErr instanceof DOMException && previewErr.name === 'AbortError') return;
|
||||
// Preview failed — fall through to the spinner (old behaviour).
|
||||
// We'll wait for the full audio to finish instead.
|
||||
audioStore.isPreview = false;
|
||||
}
|
||||
|
||||
await finishProgress();
|
||||
// Background: poll for full audio; when done, swap src preserving position.
|
||||
try {
|
||||
const final = await pollAudioStatus(slug, chapter, voice, 2000, previewAbort.signal);
|
||||
if (final.status === 'failed') {
|
||||
throw new Error(
|
||||
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
|
||||
);
|
||||
}
|
||||
|
||||
// Audio is ready in MinIO — always use a presigned URL for direct playback.
|
||||
const doneUrl = await tryPresign(slug, chapter, voice);
|
||||
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
|
||||
audioStore.audioUrl = doneUrl.url;
|
||||
audioStore.status = 'ready';
|
||||
// Don't restore time for freshly generated audio — position is 0
|
||||
// Immediately start pre-generating the next chapter in background.
|
||||
maybeStartPrefetch();
|
||||
await finishProgress();
|
||||
|
||||
const doneUrl = await tryPresign(slug, chapter, voice);
|
||||
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
|
||||
|
||||
// Swap: save currentTime → update URL → seek to saved position.
|
||||
const savedTime = audioStore.currentTime;
|
||||
const blobUrlToRevoke = audioStore.audioUrl; // capture before overwrite
|
||||
audioStore.isPreview = false;
|
||||
audioStore.audioUrl = doneUrl.url;
|
||||
// If we never started a preview (preview fetch failed), switch to ready now.
|
||||
if (audioStore.status !== 'ready') audioStore.status = 'ready';
|
||||
// The layout $effect will load the new src and auto-play from 0.
|
||||
// We seek back to savedTime after a short delay to let the element
|
||||
// attach the new source before accepting a seek.
|
||||
if (savedTime > 0) {
|
||||
setTimeout(() => {
|
||||
audioStore.seekRequest = savedTime;
|
||||
}, 300);
|
||||
}
|
||||
// Revoke the preview blob URL to free memory.
|
||||
// (We need to wait until the new src is playing; 2 s is safe.)
|
||||
setTimeout(() => {
|
||||
if (blobUrlToRevoke.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(blobUrlToRevoke);
|
||||
}
|
||||
}, 2000);
|
||||
maybeStartPrefetch();
|
||||
} catch (pollErr: unknown) {
|
||||
if (pollErr instanceof DOMException && pollErr.name === 'AbortError') return;
|
||||
throw pollErr;
|
||||
}
|
||||
} catch (e) {
|
||||
stopProgress();
|
||||
audioStore.progress = 0;
|
||||
|
||||
@@ -20,7 +20,8 @@ function client(): Redis {
|
||||
_client = new Redis(url, {
|
||||
lazyConnect: false,
|
||||
enableOfflineQueue: true,
|
||||
maxRetriesPerRequest: 2
|
||||
maxRetriesPerRequest: 1,
|
||||
connectTimeout: 1500
|
||||
});
|
||||
_client.on('error', (err: Error) => {
|
||||
console.error('[cache] Valkey error:', err.message);
|
||||
|
||||
@@ -100,8 +100,8 @@ export interface User {
|
||||
let _token = '';
|
||||
let _tokenExp = 0;
|
||||
|
||||
async function getToken(): Promise<string> {
|
||||
if (_token && Date.now() < _tokenExp) return _token;
|
||||
async function getToken(forceRefresh = false): Promise<string> {
|
||||
if (!forceRefresh && _token && Date.now() < _tokenExp) return _token;
|
||||
|
||||
log.debug('pocketbase', 'authenticating with admin credentials', { url: PB_URL, email: PB_EMAIL });
|
||||
|
||||
@@ -119,7 +119,9 @@ async function getToken(): Promise<string> {
|
||||
|
||||
const data = await res.json();
|
||||
_token = data.token as string;
|
||||
_tokenExp = Date.now() + 12 * 60 * 60 * 1000; // 12 hours
|
||||
// PocketBase superuser tokens expire in ~1 hour by default.
|
||||
// Cache for 50 minutes to stay safely within that window.
|
||||
_tokenExp = Date.now() + 50 * 60 * 1000;
|
||||
log.info('pocketbase', 'admin auth token refreshed', { url: PB_URL });
|
||||
return _token;
|
||||
}
|
||||
@@ -132,6 +134,18 @@ async function pbGet<T>(path: string): Promise<T> {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) {
|
||||
// On 401/403, the token may have expired on the PocketBase side even if
|
||||
// our local TTL hasn't fired yet. Force a refresh and retry once.
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const freshToken = await getToken(true);
|
||||
const retry = await fetch(`${PB_URL}${path}`, {
|
||||
headers: { Authorization: `Bearer ${freshToken}` }
|
||||
});
|
||||
if (retry.ok) return retry.json() as Promise<T>;
|
||||
const retryBody = await retry.text().catch(() => '');
|
||||
log.error('pocketbase', 'GET failed', { path, status: retry.status, body: retryBody });
|
||||
throw new Error(`PocketBase GET ${path} failed: ${retry.status} — ${retryBody}`);
|
||||
}
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('pocketbase', 'GET failed', { path, status: res.status, body });
|
||||
throw new Error(`PocketBase GET ${path} failed: ${res.status} — ${body}`);
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
let activeChapterEl = $state<HTMLElement | null>(null);
|
||||
let listeningModeOpen = $state(false);
|
||||
|
||||
// Build time formatted in the user's local timezone (populated on mount so
|
||||
// SSR and CSR don't produce a mismatch — SSR renders nothing, hydration fills it in).
|
||||
let buildTimeLocal = $state('');
|
||||
|
||||
function setIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) activeChapterEl = node;
|
||||
return {
|
||||
@@ -51,6 +55,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown') {
|
||||
buildTimeLocal = new Date(env.PUBLIC_BUILD_TIME).toLocaleString();
|
||||
}
|
||||
});
|
||||
|
||||
// The single <audio> element that persists across navigations.
|
||||
// AudioPlayer components in chapter pages control it via audioStore.
|
||||
let audioEl = $state<HTMLAudioElement | null>(null);
|
||||
@@ -750,10 +760,9 @@
|
||||
</div>
|
||||
<!-- Build version / commit SHA / build time -->
|
||||
{#snippet buildTime()}
|
||||
{#if env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown'}
|
||||
{@const d = new Date(env.PUBLIC_BUILD_TIME)}
|
||||
{#if buildTimeLocal}
|
||||
<span class="text-(--color-muted)" title="Build time">
|
||||
· {d.toUTCString().replace(' GMT', ' UTC').replace(/:\d\d /, ' ')}
|
||||
· {buildTimeLocal}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -862,8 +871,11 @@
|
||||
{m.player_generating({ percent: String(Math.round(audioStore.progress)) })}
|
||||
</p>
|
||||
{:else if audioStore.status === 'ready'}
|
||||
<p class="text-xs text-(--color-muted) tabular-nums leading-tight">
|
||||
<p class="text-xs text-(--color-muted) tabular-nums leading-tight flex items-center gap-1.5">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
{#if audioStore.isPreview}
|
||||
<span class="px-1 py-0.5 rounded text-[10px] font-medium bg-(--color-brand)/15 text-(--color-brand) leading-none">preview</span>
|
||||
{/if}
|
||||
</p>
|
||||
{:else if audioStore.status === 'loading'}
|
||||
<p class="text-xs text-(--color-muted) leading-tight">{m.player_loading()}</p>
|
||||
@@ -916,46 +928,6 @@
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
<!-- Speed control — fixed-width pill, kept as raw button -->
|
||||
<button
|
||||
onclick={cycleSpeed}
|
||||
class="text-xs font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors px-2 py-1 rounded bg-(--color-surface-2) hover:bg-(--color-surface-3) flex-shrink-0 tabular-nums w-12 text-center"
|
||||
title={m.player_change_speed()}
|
||||
aria-label={m.player_speed_label({ speed: String(audioStore.speed) })}
|
||||
>
|
||||
{audioStore.speed}×
|
||||
</button>
|
||||
|
||||
<!-- Auto-next toggle — has absolute-positioned status dots, kept as raw button -->
|
||||
<button
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
class={cn(
|
||||
'relative p-1.5 rounded flex-shrink-0 transition-colors',
|
||||
audioStore.autoNext
|
||||
? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25'
|
||||
: 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
title={audioStore.autoNext
|
||||
? audioStore.nextStatus === 'prefetched'
|
||||
? m.player_auto_next_ready({ n: String(audioStore.nextChapter) })
|
||||
: audioStore.nextStatus === 'prefetching'
|
||||
? m.player_auto_next_preparing({ n: String(audioStore.nextChapter) })
|
||||
: m.player_auto_next_on()
|
||||
: m.player_auto_next_off()}
|
||||
aria-label={m.player_auto_next_aria({ state: audioStore.autoNext ? m.common_on() : m.common_off() })}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<!-- "skip to end" / auto-advance icon -->
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
<!-- Prefetch status dot -->
|
||||
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
|
||||
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
|
||||
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
|
||||
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else if audioStore.status === 'generating'}
|
||||
<!-- Spinner during generation -->
|
||||
<svg class="w-6 h-6 text-(--color-brand) animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
|
||||
@@ -2,7 +2,11 @@ import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, url }) => {
|
||||
export const GET: RequestHandler = async ({ params, url, locals }) => {
|
||||
if (!locals.isPro) {
|
||||
error(403, 'EPUB download requires a Pro subscription');
|
||||
}
|
||||
|
||||
const { slug } = params;
|
||||
const from = url.searchParams.get('from');
|
||||
const to = url.searchParams.get('to');
|
||||
|
||||
@@ -64,8 +64,9 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
lastChapter: null,
|
||||
userRating: 0,
|
||||
ratingAvg: { avg: 0, count: 0 },
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
isLoggedIn: !!locals.user,
|
||||
isAdmin: locals.user?.role === 'admin',
|
||||
isPro: locals.isPro,
|
||||
isLoggedIn: !!locals.user,
|
||||
currentUserId: locals.user?.id ?? '',
|
||||
scraping: true,
|
||||
taskId: body.task_id
|
||||
|
||||
@@ -326,7 +326,7 @@
|
||||
let chapNamesPreview = $state<{ number: number; old_title: string; new_title: string; edited: string }[]>([]);
|
||||
let chapNamesApplying = $state(false);
|
||||
let chapNamesResult = $state<'applied' | 'error' | ''>('');
|
||||
let chapNamesPattern = $state('Chapter {n}: {scene}');
|
||||
let chapNamesPattern = $state('{scene}');
|
||||
let chapNamesBatchProgress = $state('');
|
||||
let chapNamesBatchWarnings = $state<string[]>([]);
|
||||
|
||||
@@ -342,7 +342,7 @@
|
||||
const res = await fetch('/api/admin/text-gen/chapter-names', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, pattern: chapNamesPattern.trim() || 'Chapter {n}: {scene}' })
|
||||
body: JSON.stringify({ slug, pattern: chapNamesPattern.trim() || '{scene}' })
|
||||
});
|
||||
if (!res.ok) {
|
||||
chapNamesResult = 'error';
|
||||
@@ -835,13 +835,22 @@
|
||||
<p class="text-sm font-medium text-(--color-text)">Download</p>
|
||||
<p class="text-xs text-(--color-muted)">All {chapterList.length} chapters as EPUB</p>
|
||||
</div>
|
||||
<a
|
||||
href="/api/export/{book.slug}"
|
||||
download="{book.slug}.epub"
|
||||
class="px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm font-medium text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex-shrink-0"
|
||||
>
|
||||
.epub
|
||||
</a>
|
||||
{#if data.isPro}
|
||||
<a
|
||||
href="/api/export/{book.slug}"
|
||||
download="{book.slug}.epub"
|
||||
class="px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm font-medium text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex-shrink-0"
|
||||
>
|
||||
.epub
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
href="/profile"
|
||||
class="px-3 py-1.5 rounded-lg bg-(--color-brand)/15 border border-(--color-brand)/30 text-sm font-medium text-(--color-brand) hover:bg-(--color-brand)/25 transition-colors flex-shrink-0"
|
||||
>
|
||||
Pro
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1135,7 +1144,7 @@
|
||||
<input
|
||||
type="text"
|
||||
bind:value={chapNamesPattern}
|
||||
placeholder="Chapter {'{n}'}: {'{scene}'}"
|
||||
placeholder="{'{scene}'}"
|
||||
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)"
|
||||
/>
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
|
||||
Reference in New Issue
Block a user