- svelte.config.js: paths.relative=false so CSS uses absolute /_app/ paths (fixes blank home page after redirect)
- ai-jobs: fix openReview() mutating stale alias r instead of $state review — was causing 'Loading results...' to never resolve for chapter-names/image-gen/description
- notifications bell: redesign with All/Unread tabs, per-item dismiss (×), mark-all-read, clear-all, 'View all' footer link
- /admin/notifications: new dedicated full-page notifications view
- api/notifications proxy: add PATCH (mark-all-read) and DELETE (clear-all, dismiss) handlers
- runner: add CreateNotification calls on success/failure in runScrapeTask, runAudioTask, runTranslationTask
- storage/import.go: real PDF (dslipak/pdf) and EPUB (archive/zip + x/net/html) parsing replacing stubs
- translation admin page: stream jobs Promise instead of blocking navigation
- store.go: DeleteNotification, ClearAllNotifications, MarkAllNotificationsRead methods
- handlers_notifications.go + server.go: PATCH /api/notifications, DELETE /api/notifications, DELETE /api/notifications/{id}
470 lines
18 KiB
Svelte
470 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { enhance } from '$app/forms';
|
|
import type { PageData, ActionData } from './$types';
|
|
import type { TranslationJob } from '$lib/server/pocketbase';
|
|
import * as m from '$lib/paraglide/messages.js';
|
|
|
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
|
|
|
let jobs = $state<TranslationJob[]>([]);
|
|
|
|
// Resolve streamed promise; re-runs on server reloads (invalidateAll)
|
|
$effect(() => {
|
|
Promise.resolve(data.jobs).then((resolved) => { jobs = resolved; });
|
|
});
|
|
|
|
// ── 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(async () => {
|
|
const res = await fetch('/api/admin/translation-jobs').catch(() => null);
|
|
if (res?.ok) {
|
|
const body = await res.json().catch(() => null);
|
|
if (body?.jobs) jobs = body.jobs;
|
|
}
|
|
}, 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)';
|
|
if (status === 'cancelled') return 'text-(--color-muted)';
|
|
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 jobsStatusFilter = $state('all');
|
|
const JOB_STATUS_OPTIONS = ['all', 'running', 'pending', 'done', 'failed', 'cancelled'] as const;
|
|
|
|
let filteredJobs = $derived(
|
|
jobs.filter((j: TranslationJob) => {
|
|
const qLower = jobsQ.trim().toLowerCase();
|
|
const matchesQ =
|
|
!qLower ||
|
|
j.slug.toLowerCase().includes(qLower) ||
|
|
j.lang.toLowerCase().includes(qLower) ||
|
|
j.status.toLowerCase().includes(qLower);
|
|
const matchesStatus = jobsStatusFilter === 'all' || j.status === jobsStatusFilter;
|
|
return matchesQ && matchesStatus;
|
|
})
|
|
);
|
|
|
|
let stats = $derived({
|
|
total: jobs.length,
|
|
done: jobs.filter((j: TranslationJob) => j.status === 'done').length,
|
|
failed: jobs.filter((j: TranslationJob) => j.status === 'failed').length,
|
|
running: jobs.filter((j: TranslationJob) => j.status === 'running').length,
|
|
pending: jobs.filter((j: TranslationJob) => j.status === 'pending').length,
|
|
cancelled: jobs.filter((j: TranslationJob) => j.status === 'cancelled').length,
|
|
inFlight: jobs.filter((j: TranslationJob) => j.status === 'pending' || j.status === 'running').length
|
|
});
|
|
|
|
// ── Cancel single job ────────────────────────────────────────────────────────
|
|
let cancellingJobIds = $state(new Set<string>());
|
|
let cancelJobErrors: Record<string, string> = $state({});
|
|
|
|
async function cancelJob(id: string) {
|
|
if (cancellingJobIds.has(id)) return;
|
|
cancellingJobIds = new Set([...cancellingJobIds, id]);
|
|
delete cancelJobErrors[id];
|
|
try {
|
|
const res = await fetch(`/api/admin/ai-jobs/${encodeURIComponent(id)}/cancel`, { method: 'POST' });
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
cancelJobErrors = { ...cancelJobErrors, [id]: body.error ?? `Error ${res.status}` };
|
|
} else {
|
|
jobs = jobs.map((j: TranslationJob) => j.id === id ? { ...j, status: 'cancelled' } : j);
|
|
}
|
|
} catch {
|
|
cancelJobErrors = { ...cancelJobErrors, [id]: 'Network error.' };
|
|
} finally {
|
|
cancellingJobIds = new Set([...cancellingJobIds].filter((x) => x !== id));
|
|
}
|
|
}
|
|
|
|
// ── Retry failed job ─────────────────────────────────────────────────────────
|
|
let retryingJobIds = $state(new Set<string>());
|
|
let retryJobErrors: Record<string, string> = $state({});
|
|
|
|
async function retryJob(job: TranslationJob) {
|
|
if (retryingJobIds.has(job.id)) return;
|
|
retryingJobIds = new Set([...retryingJobIds, job.id]);
|
|
delete retryJobErrors[job.id];
|
|
try {
|
|
const res = await fetch('/api/admin/translation/bulk', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ slug: job.slug, lang: job.lang, from: job.chapter, to: job.chapter })
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
retryJobErrors = { ...retryJobErrors, [job.id]: body.error ?? `Error ${res.status}` };
|
|
} else {
|
|
jobs = jobs.map((j: TranslationJob) => j.id === job.id ? { ...j, status: 'pending', error_message: '' } : j);
|
|
}
|
|
} catch {
|
|
retryJobErrors = { ...retryJobErrors, [job.id]: 'Network error.' };
|
|
} finally {
|
|
retryingJobIds = new Set([...retryingJobIds].filter((x) => x !== job.id));
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{m.admin_translation_page_title()}</title>
|
|
</svelte:head>
|
|
|
|
<div class="space-y-6">
|
|
<!-- Header -->
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-(--color-text)">{m.admin_translation_heading()}</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)'}"
|
|
>
|
|
{m.admin_translation_tab_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)'}"
|
|
>
|
|
{m.admin_translation_tab_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'}
|
|
<div class="flex flex-wrap gap-3 items-center">
|
|
<input
|
|
type="search"
|
|
bind:value={jobsQ}
|
|
placeholder={m.admin_translation_filter_placeholder()}
|
|
class="flex-1 min-w-48 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)"
|
|
/>
|
|
<div class="flex gap-1 flex-wrap">
|
|
{#each JOB_STATUS_OPTIONS as s}
|
|
{@const count = s === 'all' ? stats.total : (stats as Record<string, number>)[s] ?? 0}
|
|
<button
|
|
onclick={() => (jobsStatusFilter = s)}
|
|
class="px-2.5 py-1 rounded-md text-xs font-medium transition-colors capitalize
|
|
{jobsStatusFilter === s
|
|
? 'bg-(--color-brand) text-black'
|
|
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
|
|
>
|
|
{s}{count > 0 ? ` ${count}` : ''}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
{#if filteredJobs.length === 0}
|
|
<p class="text-(--color-muted) text-sm py-8 text-center">
|
|
{jobsQ.trim() ? m.admin_translation_no_matching() : m.admin_translation_no_jobs()}
|
|
</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>
|
|
<th class="px-4 py-3 text-left">Actions</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
|
|
>
|
|
<td class="px-4 py-3">
|
|
{#if job.status === 'pending' || job.status === 'running'}
|
|
<button
|
|
onclick={() => cancelJob(job.id)}
|
|
disabled={cancellingJobIds.has(job.id)}
|
|
class="px-2 py-1 rounded text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
|
|
>
|
|
{cancellingJobIds.has(job.id) ? 'Cancelling…' : 'Cancel'}
|
|
</button>
|
|
{/if}
|
|
{#if job.status === 'failed'}
|
|
<button
|
|
onclick={() => retryJob(job)}
|
|
disabled={retryingJobIds.has(job.id)}
|
|
class="px-2 py-1 rounded text-xs font-medium bg-sky-400/10 text-sky-400 hover:bg-sky-400/20 disabled:opacity-50 transition-colors"
|
|
>
|
|
{retryingJobIds.has(job.id) ? 'Retrying…' : 'Retry ↺'}
|
|
</button>
|
|
{/if}
|
|
{#if cancelJobErrors[job.id]}
|
|
<p class="text-xs text-(--color-danger) mt-1">{cancelJobErrors[job.id]}</p>
|
|
{/if}
|
|
{#if retryJobErrors[job.id]}
|
|
<p class="text-xs text-(--color-danger) mt-1">{retryJobErrors[job.id]}</p>
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{#if job.error_message}
|
|
<tr class="bg-(--color-danger)/10">
|
|
<td colspan="7" 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}
|
|
{#if job.status === 'pending' || job.status === 'running'}
|
|
<button
|
|
onclick={() => cancelJob(job.id)}
|
|
disabled={cancellingJobIds.has(job.id)}
|
|
class="w-full px-3 py-1.5 rounded-lg text-xs font-medium bg-(--color-danger)/10 text-(--color-danger) hover:bg-(--color-danger)/20 disabled:opacity-50 transition-colors"
|
|
>
|
|
{cancellingJobIds.has(job.id) ? 'Cancelling…' : 'Cancel'}
|
|
</button>
|
|
{/if}
|
|
{#if job.status === 'failed'}
|
|
<button
|
|
onclick={() => retryJob(job)}
|
|
disabled={retryingJobIds.has(job.id)}
|
|
class="w-full px-3 py-1.5 rounded-lg text-xs font-medium bg-sky-400/10 text-sky-400 hover:bg-sky-400/20 disabled:opacity-50 transition-colors"
|
|
>
|
|
{retryingJobIds.has(job.id) ? 'Retrying…' : 'Retry ↺'}
|
|
</button>
|
|
{/if}
|
|
{#if cancelJobErrors[job.id]}
|
|
<p class="text-xs text-(--color-danger)">{cancelJobErrors[job.id]}</p>
|
|
{/if}
|
|
{#if retryJobErrors[job.id]}
|
|
<p class="text-xs text-(--color-danger)">{retryJobErrors[job.id]}</p>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|