feat(translation): machine translation pipeline + admin bulk enqueue UI
Some checks failed
CI / Backend (push) Failing after 11s
Release / Check ui (push) Failing after 51s
Release / Docker / ui (push) Has been skipped
CI / UI (push) Failing after 55s
Release / Test backend (push) Failing after 1m9s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
Release / Docker / caddy (push) Failing after 28s
Release / Gitea Release (push) Has been skipped
CI / UI (pull_request) Failing after 42s
CI / Backend (pull_request) Successful in 3m45s
Some checks failed
CI / Backend (push) Failing after 11s
Release / Check ui (push) Failing after 51s
Release / Docker / ui (push) Has been skipped
CI / UI (push) Failing after 55s
Release / Test backend (push) Failing after 1m9s
Release / Docker / backend (push) Has been skipped
Release / Docker / runner (push) Has been skipped
Release / Docker / caddy (push) Failing after 28s
Release / Gitea Release (push) Has been skipped
CI / UI (pull_request) Failing after 42s
CI / Backend (pull_request) Successful in 3m45s
- LibreTranslate client (chunks on blank lines, ≤4500 chars, 3-goroutine semaphore)
- Runner translation task loop (OTel, heartbeat, MinIO storage)
- PocketBase translation_jobs collection support (create/claim/finish/list)
- Per-chapter language switcher on chapter reader (EN/RU/ID/PT/FR, polls until done)
- Admin /admin/translation page: bulk enqueue form + live-polling jobs table
- New backend routes: POST /api/translation/{slug}/{n}, GET /api/translation/status,
GET /api/translation/{slug}/{n}, GET /api/admin/translation/jobs,
POST /api/admin/translation/bulk
- ListTranslationTasks added to taskqueue.Reader interface + store impl
- All builds and tests pass; svelte-check: 0 errors
This commit is contained in:
@@ -917,6 +917,24 @@ export async function listAudioJobs(): Promise<AudioJob[]> {
|
||||
return listAll<AudioJob>('audio_jobs', '', '-started');
|
||||
}
|
||||
|
||||
// ─── Translation jobs ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface TranslationJob {
|
||||
id: string;
|
||||
cache_key: string; // "slug/chapter/lang"
|
||||
slug: string;
|
||||
chapter: number;
|
||||
lang: string;
|
||||
status: string; // "pending" | "running" | "done" | "failed"
|
||||
error_message: string;
|
||||
started: string;
|
||||
finished: string;
|
||||
}
|
||||
|
||||
export async function listTranslationJobs(): Promise<TranslationJob[]> {
|
||||
return listAll<TranslationJob>('translation_jobs', '', '-started');
|
||||
}
|
||||
|
||||
export async function getAudioTime(
|
||||
sessionId: string,
|
||||
slug: string,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
const internalLinks = [
|
||||
{ href: '/admin/scrape', label: 'Scrape' },
|
||||
{ href: '/admin/audio', label: 'Audio' },
|
||||
{ href: '/admin/translation', label: 'Translation' },
|
||||
{ href: '/admin/changelog', label: 'Changelog' }
|
||||
];
|
||||
|
||||
|
||||
63
ui/src/routes/admin/translation/+page.server.ts
Normal file
63
ui/src/routes/admin/translation/+page.server.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { listBooks, listTranslationJobs, type TranslationJob } from '$lib/server/pocketbase';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (locals.user?.role !== 'admin') {
|
||||
redirect(302, '/');
|
||||
}
|
||||
|
||||
const [books, jobs] = await Promise.all([
|
||||
listBooks().catch((e): Awaited<ReturnType<typeof listBooks>> => {
|
||||
log.warn('admin/translation', 'failed to load books', { err: String(e) });
|
||||
return [];
|
||||
}),
|
||||
listTranslationJobs().catch((e): TranslationJob[] => {
|
||||
log.warn('admin/translation', 'failed to load translation jobs', { err: String(e) });
|
||||
return [];
|
||||
})
|
||||
]);
|
||||
|
||||
return { books, jobs };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
bulk: async ({ request, locals }) => {
|
||||
if (locals.user?.role !== 'admin') {
|
||||
return { success: false, error: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const slug = form.get('slug')?.toString().trim() ?? '';
|
||||
const lang = form.get('lang')?.toString().trim() ?? '';
|
||||
const from = parseInt(form.get('from')?.toString() ?? '1', 10);
|
||||
const to = parseInt(form.get('to')?.toString() ?? '1', 10);
|
||||
|
||||
if (!slug || !lang) {
|
||||
return { success: false, error: 'slug and lang are required' };
|
||||
}
|
||||
if (isNaN(from) || isNaN(to) || from < 1 || to < from) {
|
||||
return { success: false, error: 'Invalid chapter range' };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await backendFetch('/api/admin/translation/bulk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, lang, from, to })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
log.error('admin/translation', 'bulk enqueue failed', { status: res.status, body });
|
||||
return { success: false, error: `Backend error ${res.status}: ${body}` };
|
||||
}
|
||||
const data = await res.json();
|
||||
return { success: true, enqueued: data.enqueued as number };
|
||||
} catch (e) {
|
||||
log.error('admin/translation', 'bulk enqueue fetch error', { err: String(e) });
|
||||
return { success: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
};
|
||||
340
ui/src/routes/admin/translation/+page.svelte
Normal file
340
ui/src/routes/admin/translation/+page.svelte
Normal file
@@ -0,0 +1,340 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import type { TranslationJob } from '$lib/server/pocketbase';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let jobs = $state<TranslationJob[]>(untrack(() => data.jobs));
|
||||
|
||||
// Keep in sync on server reloads
|
||||
$effect(() => {
|
||||
jobs = data.jobs;
|
||||
});
|
||||
|
||||
// ── Live-poll while any job is in-flight ─────────────────────────────────────
|
||||
let hasInFlight = $derived(jobs.some((j) => j.status === 'pending' || j.status === 'running'));
|
||||
|
||||
$effect(() => {
|
||||
if (!hasInFlight) return;
|
||||
const id = setInterval(() => {
|
||||
invalidateAll();
|
||||
}, 3000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
type Tab = 'enqueue' | 'jobs';
|
||||
let activeTab = $state<Tab>('enqueue');
|
||||
|
||||
// ── Bulk enqueue form ─────────────────────────────────────────────────────────
|
||||
let slugInput = $state('');
|
||||
let langInput = $state('ru');
|
||||
let fromInput = $state(1);
|
||||
let toInput = $state(1);
|
||||
let submitting = $state(false);
|
||||
|
||||
const langs = [
|
||||
{ value: 'ru', label: 'Russian (ru)' },
|
||||
{ value: 'id', label: 'Indonesian (id)' },
|
||||
{ value: 'pt', label: 'Portuguese (pt)' },
|
||||
{ value: 'fr', label: 'French (fr)' }
|
||||
];
|
||||
|
||||
// ── Jobs helpers ──────────────────────────────────────────────────────────────
|
||||
function jobStatusColor(status: string) {
|
||||
if (status === 'done') return 'text-green-400';
|
||||
if (status === 'running') return 'text-(--color-brand) animate-pulse';
|
||||
if (status === 'pending') return 'text-sky-400 animate-pulse';
|
||||
if (status === 'failed') return 'text-(--color-danger)';
|
||||
return 'text-(--color-text)';
|
||||
}
|
||||
|
||||
function fmtDate(s: string) {
|
||||
if (!s || s.startsWith('0001')) return '—';
|
||||
return new Date(s).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function duration(started: string, finished: string) {
|
||||
if (!started || !finished || started.startsWith('0001') || finished.startsWith('0001'))
|
||||
return '—';
|
||||
const ms = new Date(finished).getTime() - new Date(started).getTime();
|
||||
if (ms < 0) return '—';
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
let jobsQ = $state('');
|
||||
let filteredJobs = $derived(
|
||||
jobsQ.trim()
|
||||
? jobs.filter(
|
||||
(j) =>
|
||||
j.slug.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
|
||||
j.lang.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
|
||||
j.status.toLowerCase().includes(jobsQ.toLowerCase().trim())
|
||||
)
|
||||
: jobs
|
||||
);
|
||||
|
||||
let stats = $derived({
|
||||
total: jobs.length,
|
||||
done: jobs.filter((j) => j.status === 'done').length,
|
||||
failed: jobs.filter((j) => j.status === 'failed').length,
|
||||
inFlight: jobs.filter((j) => j.status === 'pending' || j.status === 'running').length
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Translation — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">Machine Translation</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
{stats.total} job{stats.total !== 1 ? 's' : ''} ·
|
||||
<span class="text-green-400">{stats.done} done</span>
|
||||
{#if stats.failed > 0}
|
||||
· <span class="text-(--color-danger)">{stats.failed} failed</span>
|
||||
{/if}
|
||||
{#if stats.inFlight > 0}
|
||||
· <span class="text-(--color-brand) animate-pulse">{stats.inFlight} in-flight</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 bg-(--color-surface-2) rounded-lg p-1 w-fit border border-(--color-border)">
|
||||
<button
|
||||
onclick={() => (activeTab = 'enqueue')}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{activeTab === 'enqueue' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Enqueue
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'jobs')}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{activeTab === 'jobs' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Jobs
|
||||
{#if stats.inFlight > 0}
|
||||
<span
|
||||
class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[10px] font-bold"
|
||||
>
|
||||
{stats.inFlight}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Enqueue tab ─────────────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'enqueue'}
|
||||
<div class="max-w-lg space-y-5">
|
||||
<!-- Result banner -->
|
||||
{#if form?.success}
|
||||
<div class="rounded-lg border border-green-500/40 bg-green-500/10 px-4 py-3 text-sm text-green-400">
|
||||
Enqueued {form.enqueued} translation job{form.enqueued !== 1 ? 's' : ''} successfully.
|
||||
</div>
|
||||
{:else if form?.error}
|
||||
<div class="rounded-lg border border-(--color-danger)/40 bg-(--color-danger)/10 px-4 py-3 text-sm text-(--color-danger)">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/bulk"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
submitting = false;
|
||||
activeTab = 'jobs';
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- Book slug -->
|
||||
<div class="space-y-1">
|
||||
<label for="slug" class="block text-sm font-medium text-(--color-text)">Book slug</label>
|
||||
<input
|
||||
id="slug"
|
||||
name="slug"
|
||||
type="text"
|
||||
required
|
||||
list="book-slugs"
|
||||
bind:value={slugInput}
|
||||
placeholder="e.g. the-beginning-after-the-end"
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
<datalist id="book-slugs">
|
||||
{#each data.books as book}
|
||||
<option value={book.slug}>{book.title}</option>
|
||||
{/each}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<!-- Language -->
|
||||
<div class="space-y-1">
|
||||
<label for="lang" class="block text-sm font-medium text-(--color-text)">Target language</label>
|
||||
<select
|
||||
id="lang"
|
||||
name="lang"
|
||||
bind:value={langInput}
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
>
|
||||
{#each langs as l}
|
||||
<option value={l.value}>{l.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Chapter range -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1">
|
||||
<label for="from" class="block text-sm font-medium text-(--color-text)">From chapter</label>
|
||||
<input
|
||||
id="from"
|
||||
name="from"
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
bind:value={fromInput}
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label for="to" class="block text-sm font-medium text-(--color-text)">To chapter</label>
|
||||
<input
|
||||
id="to"
|
||||
name="to"
|
||||
type="number"
|
||||
min="1"
|
||||
required
|
||||
bind:value={toInput}
|
||||
class="w-full bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
Enqueues {Math.max(0, toInput - fromInput + 1)} task{toInput - fromInput + 1 !== 1 ? 's' : ''} — one per chapter. Max 1000 at a time.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="w-full sm:w-auto px-6 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? 'Enqueueing…' : 'Enqueue translations'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Jobs tab ───────────────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'jobs'}
|
||||
<input
|
||||
type="search"
|
||||
bind:value={jobsQ}
|
||||
placeholder="Filter by slug, lang, or status…"
|
||||
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
|
||||
{#if filteredJobs.length === 0}
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">
|
||||
{jobsQ.trim() ? 'No matching jobs.' : 'No translation jobs yet.'}
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden sm:block overflow-x-auto rounded-xl border border-(--color-border)">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-(--color-surface-2) text-(--color-muted) text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Book</th>
|
||||
<th class="px-4 py-3 text-right">Ch.</th>
|
||||
<th class="px-4 py-3 text-left">Lang</th>
|
||||
<th class="px-4 py-3 text-left">Status</th>
|
||||
<th class="px-4 py-3 text-left">Started</th>
|
||||
<th class="px-4 py-3 text-left">Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-(--color-border)/50">
|
||||
{#each filteredJobs as job}
|
||||
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
|
||||
<td class="px-4 py-3 text-(--color-text) font-medium">
|
||||
<a href="/books/{job.slug}" class="hover:text-(--color-brand) transition-colors"
|
||||
>{job.slug}</a
|
||||
>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-(--color-muted)">{job.chapter}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) font-mono text-xs uppercase">{job.lang}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(job.started)}</td>
|
||||
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap"
|
||||
>{duration(job.started, job.finished)}</td
|
||||
>
|
||||
</tr>
|
||||
{#if job.error_message}
|
||||
<tr class="bg-(--color-danger)/10">
|
||||
<td colspan="6" class="px-4 py-2 text-xs text-(--color-danger) font-mono"
|
||||
>{job.error_message}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div class="sm:hidden space-y-3">
|
||||
{#each filteredJobs as job}
|
||||
<div class="bg-(--color-surface) rounded-xl border border-(--color-border) p-4 space-y-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<a
|
||||
href="/books/{job.slug}"
|
||||
class="text-(--color-text) font-medium hover:text-(--color-brand) transition-colors truncate"
|
||||
>
|
||||
{job.slug}
|
||||
</a>
|
||||
<span class="shrink-0 text-xs font-semibold {jobStatusColor(job.status)}"
|
||||
>{job.status}</span
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1 text-xs">
|
||||
<span class="text-(--color-muted)">Chapter</span><span
|
||||
class="text-(--color-muted) text-right">{job.chapter}</span
|
||||
>
|
||||
<span class="text-(--color-muted)">Lang</span><span
|
||||
class="text-(--color-muted) font-mono text-right uppercase">{job.lang}</span
|
||||
>
|
||||
<span class="text-(--color-muted)">Started</span><span
|
||||
class="text-(--color-muted) text-right">{fmtDate(job.started)}</span
|
||||
>
|
||||
<span class="text-(--color-muted)">Duration</span><span
|
||||
class="text-(--color-muted) text-right">{duration(job.started, job.finished)}</span
|
||||
>
|
||||
</div>
|
||||
{#if job.error_message}
|
||||
<p class="text-xs text-(--color-danger) font-mono break-all">{job.error_message}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -6,6 +6,8 @@ import { log } from '$lib/server/logger';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
import type { Voice } from '$lib/types';
|
||||
|
||||
const SUPPORTED_LANGS = new Set(['ru', 'id', 'pt', 'fr']);
|
||||
|
||||
export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
const { slug } = params;
|
||||
const n = parseInt(params.n, 10);
|
||||
@@ -15,6 +17,8 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
const isPreview = url.searchParams.get('preview') === '1';
|
||||
const chapterUrl = url.searchParams.get('chapter_url') ?? '';
|
||||
const chapterTitle = url.searchParams.get('title') ?? '';
|
||||
const lang = url.searchParams.get('lang') ?? '';
|
||||
const useTranslation = SUPPORTED_LANGS.has(lang);
|
||||
|
||||
if (isPreview) {
|
||||
// ── Preview path: scrape chapter live, nothing from PocketBase/MinIO ──
|
||||
@@ -77,7 +81,9 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
next: null as number | null,
|
||||
chapters: [] as { number: number; title: string }[],
|
||||
sessionId: locals.sessionId,
|
||||
isPreview: true
|
||||
isPreview: true,
|
||||
lang: '',
|
||||
translationStatus: 'unavailable' as string
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,7 +111,37 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
// Non-critical — UI will use store default
|
||||
}
|
||||
|
||||
// Fetch chapter markdown directly from the backend (server-side MinIO read)
|
||||
// ── Translation path: try to serve translated HTML ─────────────────────
|
||||
if (useTranslation) {
|
||||
try {
|
||||
const tRes = await backendFetch(
|
||||
`/api/translation/${encodeURIComponent(slug)}/${n}?lang=${lang}`
|
||||
);
|
||||
if (tRes.ok) {
|
||||
const tData = (await tRes.json()) as { html: string; lang: string };
|
||||
const prevChapter = chapters.find((c) => c.number === n - 1) ?? null;
|
||||
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
|
||||
return {
|
||||
book: { slug: book.slug, title: book.title, cover: book.cover ?? '' },
|
||||
chapter: chapterIdx,
|
||||
html: tData.html,
|
||||
voices,
|
||||
prev: prevChapter ? prevChapter.number : null,
|
||||
next: nextChapter ? nextChapter.number : null,
|
||||
chapters: chapters.map((c) => ({ number: c.number, title: c.title })),
|
||||
sessionId: locals.sessionId,
|
||||
isPreview: false,
|
||||
lang,
|
||||
translationStatus: 'done'
|
||||
};
|
||||
}
|
||||
// 404 = not generated yet — fall through to original, UI can trigger generation
|
||||
} catch {
|
||||
// Non-critical — fall through to original content
|
||||
}
|
||||
}
|
||||
|
||||
// ── Original content path ──────────────────────────────────────────────
|
||||
let html = '';
|
||||
try {
|
||||
const res = await backendFetch(`/api/chapter-markdown/${encodeURIComponent(slug)}/${n}`);
|
||||
@@ -122,6 +158,22 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
error(502, 'Could not fetch chapter content');
|
||||
}
|
||||
|
||||
// Check translation status for the UI switcher (non-blocking)
|
||||
let translationStatus = 'idle';
|
||||
if (useTranslation) {
|
||||
try {
|
||||
const stRes = await backendFetch(
|
||||
`/api/translation/status/${encodeURIComponent(slug)}/${n}?lang=${lang}`
|
||||
);
|
||||
if (stRes.ok) {
|
||||
const stData = (await stRes.json()) as { status: string };
|
||||
translationStatus = stData.status ?? 'idle';
|
||||
}
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
const prevChapter = chapters.find((c) => c.number === n - 1) ?? null;
|
||||
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
|
||||
|
||||
@@ -134,6 +186,8 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
|
||||
next: nextChapter ? nextChapter.number : null,
|
||||
chapters: chapters.map((c) => ({ number: c.number, title: c.title })),
|
||||
sessionId: locals.sessionId,
|
||||
isPreview: false
|
||||
isPreview: false,
|
||||
lang: useTranslation ? lang : '',
|
||||
translationStatus
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
@@ -10,7 +12,69 @@
|
||||
let fetchingContent = $state(untrack(() => !data.isPreview && !data.html));
|
||||
let fetchError = $state('');
|
||||
|
||||
onMount(async () => {
|
||||
// Translation state
|
||||
const SUPPORTED_LANGS = [
|
||||
{ code: 'ru', label: 'RU' },
|
||||
{ code: 'id', label: 'ID' },
|
||||
{ code: 'pt', label: 'PT' },
|
||||
{ code: 'fr', label: 'FR' }
|
||||
];
|
||||
let translationStatus = $state(untrack(() => data.translationStatus ?? 'idle'));
|
||||
let translatingLang = $state(untrack(() => data.lang ?? ''));
|
||||
let pollingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function currentLang() {
|
||||
return page.url.searchParams.get('lang') ?? '';
|
||||
}
|
||||
|
||||
function langUrl(lang: string) {
|
||||
const u = new URL(page.url);
|
||||
if (lang) u.searchParams.set('lang', lang);
|
||||
else u.searchParams.delete('lang');
|
||||
return u.pathname + u.search;
|
||||
}
|
||||
|
||||
async function requestTranslation(lang: string) {
|
||||
translatingLang = lang;
|
||||
translationStatus = 'pending';
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/translation/${encodeURIComponent(data.book.slug)}/${data.chapter.number}?lang=${lang}`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
const d = (await res.json()) as { status: string };
|
||||
translationStatus = d.status ?? 'pending';
|
||||
if (d.status === 'done') {
|
||||
goto(langUrl(lang));
|
||||
} else {
|
||||
startPolling(lang);
|
||||
}
|
||||
} catch {
|
||||
translationStatus = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling(lang: string) {
|
||||
if (pollingTimer) clearTimeout(pollingTimer);
|
||||
pollingTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/translation/status/${encodeURIComponent(data.book.slug)}/${data.chapter.number}?lang=${lang}`
|
||||
);
|
||||
const d = (await res.json()) as { status: string };
|
||||
translationStatus = d.status ?? 'idle';
|
||||
if (d.status === 'done') {
|
||||
goto(langUrl(lang));
|
||||
} else if (d.status === 'pending' || d.status === 'running') {
|
||||
startPolling(lang);
|
||||
}
|
||||
} catch {
|
||||
startPolling(lang); // retry on network error
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Umami analytics: track chapter reads
|
||||
window.umami?.track('chapter_read', {
|
||||
slug: data.book.slug,
|
||||
@@ -20,7 +84,7 @@
|
||||
// Record reading progress (skip for preview chapters)
|
||||
if (!data.isPreview) {
|
||||
try {
|
||||
await fetch('/api/progress', {
|
||||
fetch('/api/progress', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug: data.book.slug, chapter: data.chapter.number })
|
||||
@@ -30,26 +94,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Resume polling if translation was in-progress at load time
|
||||
if (translatingLang && (translationStatus === 'pending' || translationStatus === 'running')) {
|
||||
startPolling(translatingLang);
|
||||
}
|
||||
|
||||
// If the normal path returned no content, fall back to live preview scrape
|
||||
if (!data.isPreview && !data.html) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/chapter-text-preview/${encodeURIComponent(data.book.slug)}/${data.chapter.number}`
|
||||
);
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const d = (await res.json()) as { text?: string };
|
||||
if (d.text) {
|
||||
const { marked } = await import('marked');
|
||||
html = await marked(d.text, { async: true });
|
||||
} else {
|
||||
fetchError = m.reader_audio_error();
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/chapter-text-preview/${encodeURIComponent(data.book.slug)}/${data.chapter.number}`
|
||||
);
|
||||
if (!res.ok) throw new Error(`status ${res.status}`);
|
||||
const d = (await res.json()) as { text?: string };
|
||||
if (d.text) {
|
||||
const { marked } = await import('marked');
|
||||
html = await marked(d.text, { async: true });
|
||||
} else {
|
||||
fetchError = m.reader_audio_error();
|
||||
}
|
||||
} catch {
|
||||
fetchError = m.reader_fetching_chapter();
|
||||
} finally {
|
||||
fetchingContent = false;
|
||||
}
|
||||
} catch (e) {
|
||||
fetchError = m.reader_fetching_chapter();
|
||||
} finally {
|
||||
fetchingContent = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingTimer) clearTimeout(pollingTimer);
|
||||
};
|
||||
});
|
||||
|
||||
const wordCount = $derived(
|
||||
@@ -94,7 +169,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Chapter heading -->
|
||||
<div class="mb-6">
|
||||
<div class="mb-4">
|
||||
<h1 class="text-xl font-bold text-(--color-text)">
|
||||
{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
</h1>
|
||||
@@ -103,6 +178,47 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Language switcher (not shown for preview chapters) -->
|
||||
{#if !data.isPreview}
|
||||
<div class="flex items-center gap-2 mb-6 flex-wrap">
|
||||
<span class="text-(--color-muted) text-xs">Read in:</span>
|
||||
|
||||
<!-- English (original) -->
|
||||
<a
|
||||
href={langUrl('')}
|
||||
class="px-2 py-0.5 rounded text-xs font-medium transition-colors {currentLang() === '' ? 'bg-(--color-brand) text-(--color-surface)' : 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
EN
|
||||
</a>
|
||||
|
||||
{#each SUPPORTED_LANGS as { code, label }}
|
||||
{#if currentLang() === code && (translationStatus === 'pending' || translationStatus === 'running')}
|
||||
<!-- Spinning indicator while translating -->
|
||||
<span class="flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted)">
|
||||
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{label}
|
||||
</span>
|
||||
{:else if currentLang() === code}
|
||||
<!-- Active translated lang -->
|
||||
<a
|
||||
href={langUrl(code)}
|
||||
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-brand) text-(--color-surface)"
|
||||
>{label}</a>
|
||||
{:else}
|
||||
<!-- Inactive lang: click to request/navigate -->
|
||||
<button
|
||||
onclick={() => requestTranslation(code)}
|
||||
disabled={translatingLang !== '' && translatingLang !== code && (translationStatus === 'pending' || translationStatus === 'running')}
|
||||
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>{label}</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Audio player -->
|
||||
{#if !data.isPreview}
|
||||
<AudioPlayer
|
||||
|
||||
Reference in New Issue
Block a user