- Admin layout: SVG icons, active highlight, divider between nav sections - Scrape page: status filter pills with counts, text + status combined search - Audio page: status filter pills, cancel jobs, retry failed jobs, mobile cards for cache tab - Translation page: status filter pills (incl. cancelled), cancel + retry jobs, mobile cancel/retry cards, i18n for all labels - AI Jobs page: fix concurrent cancel (Set instead of single slot), per-job cancel errors inline, full mobile card layout, i18n title/heading - Text-gen page: tagline editable input + copy, warnings copy, i18n title/heading - Book page: chapter cover Save button, audio monitor link, currentShelf pre-populated from server - pocketbase.ts: add getBookShelf(), shelf field on UserLibraryEntry - New API route: POST /api/admin/translation/bulk (proxy for translation retry) - i18n: 15 new admin_translation_*, admin_ai_jobs_*, admin_text_gen_* keys across all 5 locales
539 lines
22 KiB
Svelte
539 lines
22 KiB
Svelte
<script lang="ts">
|
|
import { untrack } from 'svelte';
|
|
import { invalidateAll } from '$app/navigation';
|
|
import type { PageData } from './$types';
|
|
import type { ScrapingTask } from '$lib/server/pocketbase';
|
|
import * as m from '$lib/paraglide/messages.js';
|
|
|
|
let { data }: { data: PageData } = $props();
|
|
|
|
// ── Live-poll status ────────────────────────────────────────────────────────
|
|
let running = $state(untrack(() => data.running));
|
|
let tasks = $state(untrack(() => data.tasks));
|
|
|
|
// Poll every 5 s while a job is running
|
|
$effect(() => {
|
|
if (!running) return;
|
|
const id = setInterval(async () => {
|
|
const res = await fetch('/api/admin/scrape').catch(() => null);
|
|
if (res?.ok) {
|
|
const body = await res.json().catch(() => null);
|
|
running = body?.running ?? false;
|
|
if (!running) {
|
|
await invalidateAll();
|
|
}
|
|
}
|
|
}, 5000);
|
|
return () => clearInterval(id);
|
|
});
|
|
|
|
// Keep local state in sync when server re-loads
|
|
$effect(() => {
|
|
running = data.running;
|
|
tasks = data.tasks;
|
|
});
|
|
|
|
// ── Full catalogue scrape ───────────────────────────────────────────────────
|
|
let catalogueError = $state('');
|
|
let cataloguing = $state(false);
|
|
|
|
async function triggerCatalogueScrape() {
|
|
if (running || cataloguing) return;
|
|
cataloguing = true;
|
|
catalogueError = '';
|
|
try {
|
|
const res = await fetch('/api/scrape', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: '{}'
|
|
});
|
|
if (!res.ok) {
|
|
const d = await res.json().catch(() => ({}));
|
|
catalogueError = d.error ?? d.message ?? `Error ${res.status}`;
|
|
} else {
|
|
running = true;
|
|
}
|
|
} catch {
|
|
catalogueError = 'Network error.';
|
|
} finally {
|
|
cataloguing = false;
|
|
}
|
|
}
|
|
|
|
// ── Single book scrape ──────────────────────────────────────────────────────
|
|
let scrapeUrl = $state('');
|
|
let scrapeError = $state('');
|
|
let scraping = $state(false);
|
|
|
|
async function triggerBookScrape(url: string) {
|
|
if (running || scraping || !url.trim()) return;
|
|
scraping = true;
|
|
scrapeError = '';
|
|
try {
|
|
const res = await fetch('/api/scrape', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url: url.trim() })
|
|
});
|
|
if (!res.ok) {
|
|
const d = await res.json().catch(() => ({}));
|
|
scrapeError = d.error ?? d.message ?? `Error ${res.status}`;
|
|
} else {
|
|
running = true;
|
|
scrapeUrl = '';
|
|
}
|
|
} catch {
|
|
scrapeError = 'Network error.';
|
|
} finally {
|
|
scraping = false;
|
|
}
|
|
}
|
|
|
|
// ── Range scrape ────────────────────────────────────────────────────────────
|
|
let rangeUrl = $state('');
|
|
let rangeFrom = $state<number | null>(null);
|
|
let rangeTo = $state<number | null>(null);
|
|
let rangeError = $state('');
|
|
let ranging = $state(false);
|
|
|
|
async function triggerRangeScrape() {
|
|
if (running || ranging || !rangeUrl.trim() || rangeFrom === null) return;
|
|
ranging = true;
|
|
rangeError = '';
|
|
try {
|
|
const body: Record<string, unknown> = { url: rangeUrl.trim(), from: rangeFrom };
|
|
if (rangeTo !== null) body.to = rangeTo;
|
|
const res = await fetch('/api/scrape/range', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
if (!res.ok) {
|
|
const d = await res.json().catch(() => ({}));
|
|
rangeError = d.error ?? d.message ?? `Error ${res.status}`;
|
|
} else {
|
|
running = true;
|
|
rangeUrl = '';
|
|
rangeFrom = null;
|
|
rangeTo = null;
|
|
}
|
|
} catch {
|
|
rangeError = 'Network error.';
|
|
} finally {
|
|
ranging = false;
|
|
}
|
|
}
|
|
|
|
// ── Continue / Retry task ───────────────────────────────────────────────────
|
|
function scrollToRangeForm() {
|
|
document.getElementById('range-form')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
|
|
function scrollToBookForm() {
|
|
document.getElementById('book-form')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
|
|
function continueTask(task: ScrapingTask) {
|
|
// Re-enqueue a book_range from where it left off
|
|
rangeUrl = task.target_url ?? '';
|
|
rangeFrom = (task.from_chapter ?? 1) + (task.chapters_scraped ?? 0);
|
|
rangeTo = task.to_chapter > 0 ? task.to_chapter : null;
|
|
scrollToRangeForm();
|
|
}
|
|
|
|
function retryTask(task: ScrapingTask) {
|
|
if (task.kind === 'catalogue') {
|
|
triggerCatalogueScrape();
|
|
} else if (task.kind === 'book_range') {
|
|
rangeUrl = task.target_url ?? '';
|
|
rangeFrom = task.from_chapter ?? 1;
|
|
rangeTo = task.to_chapter > 0 ? task.to_chapter : null;
|
|
scrollToRangeForm();
|
|
} else {
|
|
scrapeUrl = task.target_url ?? '';
|
|
scrollToBookForm();
|
|
}
|
|
}
|
|
|
|
// ── Cancel task ─────────────────────────────────────────────────────────────
|
|
let cancellingIds = $state(new Set<string>());
|
|
let cancelErrors: Record<string, string> = $state({});
|
|
|
|
async function cancelTask(id: string) {
|
|
if (cancellingIds.has(id)) return;
|
|
cancellingIds = new Set([...cancellingIds, id]);
|
|
delete cancelErrors[id];
|
|
try {
|
|
const res = await fetch(`/api/scrape/cancel/${encodeURIComponent(id)}`, { method: 'POST' });
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
cancelErrors = { ...cancelErrors, [id]: body.error ?? body.message ?? `Error ${res.status}` };
|
|
} else {
|
|
tasks = tasks.map((t: ScrapingTask) => (t.id === id ? { ...t, status: 'cancelled' } : t));
|
|
}
|
|
} catch {
|
|
cancelErrors = { ...cancelErrors, [id]: 'Network error.' };
|
|
} finally {
|
|
cancellingIds = new Set([...cancellingIds].filter((x) => x !== id));
|
|
}
|
|
}
|
|
|
|
// ── Stats ────────────────────────────────────────────────────────────────────
|
|
let stats = $derived({
|
|
total: tasks.length,
|
|
running: tasks.filter((t: ScrapingTask) => t.status === 'running').length,
|
|
pending: tasks.filter((t: ScrapingTask) => t.status === 'pending').length,
|
|
done: tasks.filter((t: ScrapingTask) => t.status === 'done').length,
|
|
failed: tasks.filter((t: ScrapingTask) => t.status === 'failed').length,
|
|
cancelled: tasks.filter((t: ScrapingTask) => t.status === 'cancelled').length,
|
|
});
|
|
|
|
// ── Table filter ────────────────────────────────────────────────────────────
|
|
let q = $state('');
|
|
let statusFilter = $state('all');
|
|
const STATUS_OPTIONS = ['all', 'running', 'pending', 'done', 'failed', 'cancelled'] as const;
|
|
|
|
let filtered = $derived(
|
|
tasks.filter((t: ScrapingTask) => {
|
|
const qLower = q.trim().toLowerCase();
|
|
const matchesQ =
|
|
!qLower ||
|
|
t.kind.toLowerCase().includes(qLower) ||
|
|
t.status.toLowerCase().includes(qLower) ||
|
|
(t.target_url ?? '').toLowerCase().includes(qLower);
|
|
const matchesStatus = statusFilter === 'all' || t.status === statusFilter;
|
|
return matchesQ && matchesStatus;
|
|
})
|
|
);
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
function statusColor(status: string) {
|
|
if (status === 'done') return 'text-green-400';
|
|
if (status === 'running') return 'text-(--color-brand) 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) 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) 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`;
|
|
}
|
|
|
|
// Popular novelfire genres for quick-scrape links
|
|
const quickScrapes = [
|
|
{ label: 'Action', url: 'https://novelfire.net/genre/action' },
|
|
{ label: 'Fantasy', url: 'https://novelfire.net/genre/fantasy' },
|
|
{ label: 'Romance', url: 'https://novelfire.net/genre/romance' },
|
|
{ label: 'System', url: 'https://novelfire.net/genre/system' },
|
|
{ label: 'Isekai', url: 'https://novelfire.net/genre/isekai' },
|
|
{ label: 'Martial Arts', url: 'https://novelfire.net/genre/martial-arts' },
|
|
];
|
|
|
|
function statusPillColor(s: string) {
|
|
if (s === 'running') return 'text-(--color-brand)';
|
|
if (s === 'pending') return 'text-sky-400';
|
|
if (s === 'done') return 'text-green-400';
|
|
if (s === 'failed') return 'text-(--color-danger)';
|
|
if (s === 'cancelled') return 'text-(--color-muted)';
|
|
return 'text-(--color-text)';
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{m.admin_scrape_page_title()}</title>
|
|
</svelte:head>
|
|
|
|
<div class="space-y-6">
|
|
<!-- Header -->
|
|
<div class="flex items-center gap-3 flex-wrap">
|
|
<h1 class="text-xl font-semibold text-(--color-text) flex-1">{m.admin_scrape_heading()}</h1>
|
|
<span class="text-xs {running ? 'text-(--color-brand) animate-pulse' : 'text-green-500'}">
|
|
● {running ? m.admin_scrape_status_running() : m.admin_scrape_status_idle()}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Compact controls -->
|
|
<div class="divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
|
|
<!-- Full catalogue -->
|
|
<div class="flex items-center gap-4 px-4 py-3 bg-(--color-surface)">
|
|
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_full_catalogue()}</span>
|
|
<button
|
|
onclick={triggerCatalogueScrape}
|
|
disabled={running || cataloguing}
|
|
class="px-3 py-1.5 rounded-md bg-(--color-brand) text-(--color-surface) font-semibold text-xs hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50"
|
|
>
|
|
{cataloguing ? m.admin_scrape_queuing() : running ? m.admin_scrape_running() : m.admin_scrape_start()}
|
|
</button>
|
|
{#if catalogueError}<span class="text-xs text-(--color-danger)">{catalogueError}</span>{/if}
|
|
</div>
|
|
|
|
<!-- Single book -->
|
|
<div id="book-form" class="flex items-center gap-3 px-4 py-3 bg-(--color-surface)">
|
|
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_single_book()}</span>
|
|
<input
|
|
type="url"
|
|
bind:value={scrapeUrl}
|
|
placeholder="https://novelfire.net/book/…"
|
|
class="flex-1 min-w-0 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
|
/>
|
|
<button
|
|
onclick={() => triggerBookScrape(scrapeUrl)}
|
|
disabled={!scrapeUrl.trim() || running || scraping}
|
|
class="shrink-0 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
|
|
>
|
|
{scraping ? m.admin_scrape_queuing() : m.admin_scrape_submit()}
|
|
</button>
|
|
{#if scrapeError}<span class="text-xs text-(--color-danger)">{scrapeError}</span>{/if}
|
|
</div>
|
|
|
|
<!-- Range scrape -->
|
|
<div id="range-form" class="flex items-center gap-3 px-4 py-3 bg-(--color-surface) flex-wrap">
|
|
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_range()}</span>
|
|
<input
|
|
type="url"
|
|
bind:value={rangeUrl}
|
|
placeholder="https://novelfire.net/book/…"
|
|
class="flex-1 min-w-0 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
|
/>
|
|
<input
|
|
type="number"
|
|
bind:value={rangeFrom}
|
|
min="1"
|
|
placeholder="From"
|
|
class="w-20 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
|
/>
|
|
<input
|
|
type="number"
|
|
bind:value={rangeTo}
|
|
min="1"
|
|
placeholder="To"
|
|
class="w-20 bg-(--color-surface-2) border border-(--color-border) rounded-md px-3 py-1.5 text-(--color-text) text-sm placeholder-zinc-600 focus:outline-none focus:ring-1 focus:ring-(--color-brand)"
|
|
/>
|
|
<button
|
|
onclick={triggerRangeScrape}
|
|
disabled={!rangeUrl.trim() || rangeFrom === null || running || ranging}
|
|
class="shrink-0 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
|
|
>
|
|
{ranging ? m.admin_scrape_queuing() : 'Go'}
|
|
</button>
|
|
{#if rangeError}<span class="text-xs text-(--color-danger) w-full pl-40">{rangeError}</span>{/if}
|
|
</div>
|
|
|
|
<!-- Quick genre chips -->
|
|
<div class="flex items-center gap-3 px-4 py-3 bg-(--color-surface) flex-wrap">
|
|
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_quick_genres()}</span>
|
|
<div class="flex flex-wrap gap-1.5">
|
|
{#each quickScrapes as qs}
|
|
<button
|
|
onclick={() => { scrapeUrl = qs.url; }}
|
|
class="px-2.5 py-1 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) border border-(--color-border) hover:border-(--color-brand)/50 hover:text-(--color-brand-dim) transition-colors"
|
|
>
|
|
{qs.label}
|
|
</button>
|
|
{/each}
|
|
<a
|
|
href="https://novelfire.net"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="px-2.5 py-1 rounded text-xs font-medium text-(--color-muted) border border-(--color-border)/50 hover:text-(--color-brand-dim) hover:border-(--color-brand)/40 transition-colors"
|
|
>
|
|
novelfire.net ↗
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tasks table -->
|
|
<div class="space-y-3">
|
|
<div class="flex items-center gap-3 flex-wrap">
|
|
<h2 class="text-sm font-semibold text-(--color-muted) flex-1 uppercase tracking-widest">{m.admin_scrape_task_history()}</h2>
|
|
<input
|
|
type="search"
|
|
bind:value={q}
|
|
placeholder={m.admin_scrape_filter_placeholder()}
|
|
class="w-full max-w-xs 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>
|
|
|
|
<!-- Status filter pills -->
|
|
<div class="flex gap-1.5 flex-wrap">
|
|
{#each STATUS_OPTIONS as s}
|
|
{@const count = s === 'all' ? stats.total : stats[s as keyof typeof stats]}
|
|
<button
|
|
onclick={() => (statusFilter = s)}
|
|
class="px-2.5 py-1 rounded-md text-xs font-medium transition-colors capitalize flex items-center gap-1.5
|
|
{statusFilter === s
|
|
? 'bg-(--color-brand) text-black'
|
|
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
|
|
>
|
|
{s}
|
|
{#if count > 0 && s !== 'all'}
|
|
<span class="tabular-nums {statusFilter === s ? 'text-black/70' : statusPillColor(s)}">{count}</span>
|
|
{:else if s === 'all'}
|
|
<span class="tabular-nums opacity-60">{count}</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if filtered.length === 0}
|
|
<p class="text-(--color-muted) text-sm py-8 text-center">
|
|
{q.trim() ? m.admin_scrape_no_matching() : m.admin_tasks_empty()}
|
|
</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">Kind / URL</th>
|
|
<th class="px-4 py-3 text-left">Status</th>
|
|
<th class="px-4 py-3 text-right">Books</th>
|
|
<th class="px-4 py-3 text-right">Chapters</th>
|
|
<th class="px-4 py-3 text-right">Skipped</th>
|
|
<th class="px-4 py-3 text-right">Errors</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 filtered as task}
|
|
<tr class="bg-(--color-surface) hover:bg-(--color-surface-2)/50 transition-colors">
|
|
<td class="px-4 py-3 font-mono text-xs text-(--color-text)">
|
|
{task.kind}
|
|
{#if task.target_url}
|
|
<br />
|
|
<span class="text-(--color-muted) truncate max-w-[16rem] block" title={task.target_url}>
|
|
{task.target_url.replace('https://novelfire.net/book/', '')}
|
|
</span>
|
|
{/if}
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<span class="font-medium {statusColor(task.status)}">{task.status}</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-right text-(--color-text)">{task.books_found ?? 0}</td>
|
|
<td class="px-4 py-3 text-right text-(--color-text)">{task.chapters_scraped ?? 0}</td>
|
|
<td class="px-4 py-3 text-right text-(--color-muted)">{task.chapters_skipped ?? 0}</td>
|
|
<td class="px-4 py-3 text-right {task.errors > 0 ? 'text-(--color-danger)' : 'text-(--color-muted)'}">{task.errors ?? 0}</td>
|
|
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{fmtDate(task.started)}</td>
|
|
<td class="px-4 py-3 text-(--color-muted) whitespace-nowrap">{duration(task.started, task.finished)}</td>
|
|
<td class="px-4 py-3">
|
|
<div class="flex flex-wrap gap-1.5">
|
|
{#if task.status === 'pending'}
|
|
<button
|
|
onclick={() => cancelTask(task.id)}
|
|
disabled={cancellingIds.has(task.id)}
|
|
class="px-2 py-1 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-text) hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
|
|
>
|
|
{cancellingIds.has(task.id) ? 'Cancelling…' : m.admin_scrape_cancel()}
|
|
</button>
|
|
{/if}
|
|
{#if task.kind === 'book_range' && task.status !== 'pending' && task.status !== 'running' && (task.chapters_scraped ?? 0) > 0}
|
|
<button
|
|
onclick={() => continueTask(task)}
|
|
class="px-2 py-1 rounded text-xs font-medium bg-amber-900/60 text-amber-300 hover:bg-amber-800/60 transition-colors"
|
|
>
|
|
Continue ▶
|
|
</button>
|
|
{/if}
|
|
{#if task.status === 'failed' || task.status === 'cancelled'}
|
|
<button
|
|
onclick={() => retryTask(task)}
|
|
class="px-2 py-1 rounded text-xs font-medium bg-sky-900/60 text-sky-300 hover:bg-sky-800/60 transition-colors"
|
|
>
|
|
Retry ↺
|
|
</button>
|
|
{/if}
|
|
{#if cancelErrors[task.id]}
|
|
<p class="text-xs text-(--color-danger) mt-1 w-full">{cancelErrors[task.id]}</p>
|
|
{/if}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{#if task.error_message}
|
|
<tr class="bg-(--color-danger)/10">
|
|
<td colspan="9" class="px-4 py-2 text-xs text-(--color-danger) font-mono">{task.error_message}</td>
|
|
</tr>
|
|
{/if}
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Mobile cards -->
|
|
<div class="sm:hidden space-y-3">
|
|
{#each filtered as task}
|
|
<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">
|
|
<div class="min-w-0">
|
|
<span class="font-mono text-xs text-(--color-text)">{task.kind}</span>
|
|
{#if task.target_url}
|
|
<p class="text-xs text-(--color-muted) truncate mt-0.5" title={task.target_url}>
|
|
{task.target_url.replace('https://novelfire.net/book/', '')}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
<span class="shrink-0 text-xs font-semibold {statusColor(task.status)}">{task.status}</span>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-1 text-xs">
|
|
<span class="text-(--color-muted)">Books</span><span class="text-(--color-text) text-right">{task.books_found ?? 0}</span>
|
|
<span class="text-(--color-muted)">Chapters</span><span class="text-(--color-text) text-right">{task.chapters_scraped ?? 0}</span>
|
|
<span class="text-(--color-muted)">Skipped</span><span class="text-(--color-muted) text-right">{task.chapters_skipped ?? 0}</span>
|
|
<span class="text-(--color-muted)">Errors</span><span class="{task.errors > 0 ? 'text-(--color-danger)' : 'text-(--color-muted)'} text-right">{task.errors ?? 0}</span>
|
|
<span class="text-(--color-muted)">Started</span><span class="text-(--color-muted) text-right">{fmtDate(task.started)}</span>
|
|
<span class="text-(--color-muted)">Duration</span><span class="text-(--color-muted) text-right">{duration(task.started, task.finished)}</span>
|
|
</div>
|
|
{#if task.error_message}
|
|
<p class="text-xs text-(--color-danger) font-mono break-all">{task.error_message}</p>
|
|
{/if}
|
|
<div class="flex flex-wrap gap-2">
|
|
{#if task.status === 'pending'}
|
|
<button
|
|
onclick={() => cancelTask(task.id)}
|
|
disabled={cancellingIds.has(task.id)}
|
|
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-(--color-surface-3) text-(--color-text) hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
|
|
>
|
|
{cancellingIds.has(task.id) ? 'Cancelling…' : m.admin_scrape_cancel()} task
|
|
</button>
|
|
{/if}
|
|
{#if task.kind === 'book_range' && task.status !== 'pending' && task.status !== 'running' && (task.chapters_scraped ?? 0) > 0}
|
|
<button
|
|
onclick={() => continueTask(task)}
|
|
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-amber-900/60 text-amber-300 hover:bg-amber-800/60 transition-colors"
|
|
>
|
|
Continue ▶
|
|
</button>
|
|
{/if}
|
|
{#if task.status === 'failed' || task.status === 'cancelled'}
|
|
<button
|
|
onclick={() => retryTask(task)}
|
|
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-sky-900/60 text-sky-300 hover:bg-sky-800/60 transition-colors"
|
|
>
|
|
Retry ↺
|
|
</button>
|
|
{/if}
|
|
{#if cancelErrors[task.id]}
|
|
<p class="text-xs text-(--color-danger) w-full">{cancelErrors[task.id]}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|