chore: migrate to v3, Doppler secrets, clean up legacy code
Some checks failed
CI / v3 / Check ui (pull_request) Failing after 15s
CI / v3 / Test backend (pull_request) Failing after 16s
CI / v3 / Docker / backend (pull_request) Has been skipped
CI / v3 / Docker / runner (pull_request) Has been skipped
CI / v3 / Docker / ui (pull_request) Has been skipped
Some checks failed
CI / v3 / Check ui (pull_request) Failing after 15s
CI / v3 / Test backend (pull_request) Failing after 16s
CI / v3 / Docker / backend (pull_request) Has been skipped
CI / v3 / Docker / runner (pull_request) Has been skipped
CI / v3 / Docker / ui (pull_request) Has been skipped
- Remove all pre-v3 code: scraper, ui-v2, backend v1, ios v1+v2, legacy CI workflows - Flatten v3/ contents to repo root - Add Doppler secrets management (project=libnovel, config=prd) - Add justfile with doppler run wrappers for all docker compose commands - Strip hardcoded env fallbacks from docker-compose.yml - Add minimal README.md - Clean up .gitignore
This commit is contained in:
56
ui/src/routes/admin/+layout.svelte
Normal file
56
ui/src/routes/admin/+layout.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
|
||||
const adminTabs = [
|
||||
{ href: '/admin/scrape', label: 'Scrape' },
|
||||
{ href: '/admin/audio', label: 'Audio' }
|
||||
];
|
||||
|
||||
const toolTabs = [
|
||||
{ href: 'https://feedback.libnovel.cc', label: 'Feedback' },
|
||||
{ href: 'https://errors.libnovel.cc', label: 'Errors' },
|
||||
{ href: 'https://analytics.libnovel.cc', label: 'Analytics' },
|
||||
{ href: 'https://logs.libnovel.cc', label: 'Logs' },
|
||||
{ href: 'https://uptime.libnovel.cc', label: 'Uptime' },
|
||||
{ href: 'https://push.libnovel.cc', label: 'Push' }
|
||||
];
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- Admin nav: internal pages + external tools -->
|
||||
<div class="mb-6 flex flex-wrap items-center gap-3">
|
||||
<!-- Internal admin pages -->
|
||||
<div class="flex gap-1 bg-zinc-800 rounded-lg p-1 border border-zinc-700">
|
||||
{#each adminTabs as tab}
|
||||
<a
|
||||
href={tab.href}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{page.url.pathname.startsWith(tab.href)
|
||||
? 'bg-zinc-700 text-zinc-100'
|
||||
: 'text-zinc-400 hover:text-zinc-200'}"
|
||||
>
|
||||
{tab.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- External tools (open in new tab) -->
|
||||
<div class="flex gap-1 bg-zinc-800 rounded-lg p-1 border border-zinc-700">
|
||||
{#each toolTabs as tool}
|
||||
<a
|
||||
href={tool.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
{tool.label} ↗
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{@render children?.()}
|
||||
@@ -1,17 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { listAudioJobs } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (locals.user?.role !== 'admin') {
|
||||
redirect(302, '/');
|
||||
}
|
||||
|
||||
const jobs = await listAudioJobs().catch((e) => {
|
||||
log.warn('admin/audio-jobs', 'failed to load audio jobs', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
|
||||
return { jobs };
|
||||
export const load: PageServerLoad = async () => {
|
||||
redirect(301, '/admin/audio');
|
||||
};
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let jobs = $state(data.jobs);
|
||||
|
||||
// ── Live-poll: refresh while any job is in-flight ────────────────────────────
|
||||
let hasInFlight = $derived(jobs.some((j) => j.status === 'pending' || j.status === 'generating'));
|
||||
|
||||
$effect(() => {
|
||||
if (!hasInFlight) return;
|
||||
const id = setInterval(async () => {
|
||||
const res = await fetch('/admin/audio-jobs?__data=1').catch(() => null);
|
||||
if (res?.ok) {
|
||||
// SvelteKit invalidateAll is cleaner — just trigger a soft navigation reload.
|
||||
import('$app/navigation').then(({ invalidateAll }) => invalidateAll());
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// Keep local state in sync when server re-loads
|
||||
$effect(() => {
|
||||
jobs = data.jobs;
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function statusColor(status: string) {
|
||||
if (status === 'done') return 'text-green-400';
|
||||
if (status === 'generating') return 'text-amber-400 animate-pulse';
|
||||
if (status === 'pending') return 'text-sky-400 animate-pulse';
|
||||
if (status === 'failed') return 'text-red-400';
|
||||
return 'text-zinc-300';
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
// ── Search ───────────────────────────────────────────────────────────────────
|
||||
let q = $state('');
|
||||
let filtered = $derived(
|
||||
q.trim()
|
||||
? jobs.filter(
|
||||
(j) =>
|
||||
j.slug.toLowerCase().includes(q.toLowerCase().trim()) ||
|
||||
j.voice.toLowerCase().includes(q.toLowerCase().trim()) ||
|
||||
j.status.toLowerCase().includes(q.toLowerCase().trim())
|
||||
)
|
||||
: jobs
|
||||
);
|
||||
|
||||
// ── Stats ────────────────────────────────────────────────────────────────────
|
||||
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 === 'generating').length
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Audio jobs — libnovel admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-start justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Audio jobs</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">
|
||||
{stats.total} total ·
|
||||
<span class="text-green-400">{stats.done} done</span> ·
|
||||
{#if stats.failed > 0}
|
||||
<span class="text-red-400">{stats.failed} failed</span> ·
|
||||
{/if}
|
||||
{#if stats.inFlight > 0}
|
||||
<span class="text-amber-400 animate-pulse">{stats.inFlight} in-flight</span>
|
||||
{:else}
|
||||
<span class="text-zinc-500">0 in-flight</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<input
|
||||
type="search"
|
||||
bind:value={q}
|
||||
placeholder="Filter by slug, voice or status…"
|
||||
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="text-zinc-500 text-sm py-8 text-center">
|
||||
{q.trim() ? 'No results.' : 'No audio jobs yet.'}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-xl border border-zinc-700">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-zinc-800 text-zinc-400 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">Voice</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-zinc-700/50">
|
||||
{#each filtered as job}
|
||||
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
|
||||
<td class="px-4 py-3 text-zinc-200 font-medium">
|
||||
<a href="/books/{job.slug}" class="hover:text-amber-400 transition-colors">
|
||||
{job.slug}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-zinc-400">{job.chapter}</td>
|
||||
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{job.voice}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-medium {statusColor(job.status)}">{job.status}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-400">{fmtDate(job.started)}</td>
|
||||
<td class="px-4 py-3 text-zinc-400">{duration(job.started, job.finished)}</td>
|
||||
</tr>
|
||||
{#if job.error_message}
|
||||
<tr class="bg-red-950/20">
|
||||
<td colspan="6" class="px-4 py-2 text-xs text-red-400 font-mono"
|
||||
>{job.error_message}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { listAudioCache } from '$lib/server/pocketbase';
|
||||
import { listAudioCache, listAudioJobs, type AudioCacheEntry, type AudioJob } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
@@ -8,10 +8,16 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
redirect(302, '/');
|
||||
}
|
||||
|
||||
const entries = await listAudioCache().catch((e) => {
|
||||
log.warn('admin/audio', 'failed to load audio cache', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
const [entries, jobs] = await Promise.all([
|
||||
listAudioCache().catch((e): AudioCacheEntry[] => {
|
||||
log.warn('admin/audio', 'failed to load audio cache', { err: String(e) });
|
||||
return [];
|
||||
}),
|
||||
listAudioJobs().catch((e): AudioJob[] => {
|
||||
log.warn('admin/audio', 'failed to load audio jobs', { err: String(e) });
|
||||
return [];
|
||||
})
|
||||
]);
|
||||
|
||||
return { entries };
|
||||
return { entries, jobs };
|
||||
};
|
||||
|
||||
@@ -1,18 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import type { AudioJob, AudioCacheEntry } from '$lib/server/pocketbase';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let entries = $state(data.entries);
|
||||
let entries = $state<AudioCacheEntry[]>(untrack(() => data.entries));
|
||||
let jobs = $state<AudioJob[]>(untrack(() => data.jobs));
|
||||
|
||||
// ── Parse cache_key ─────────────────────────────────────────────────────────
|
||||
// cache_key format: "slug/chapter/voice"
|
||||
function parseKey(key: string) {
|
||||
const parts = key.split('/');
|
||||
if (parts.length >= 3) {
|
||||
return { slug: parts[0], chapter: parts[1], voice: parts.slice(2).join('/') };
|
||||
}
|
||||
return { slug: key, chapter: '—', voice: '—' };
|
||||
// Keep in sync on server reloads
|
||||
$effect(() => {
|
||||
entries = data.entries;
|
||||
jobs = data.jobs;
|
||||
});
|
||||
|
||||
// ── Live-poll while any job is in-flight ─────────────────────────────────────
|
||||
let hasInFlight = $derived(jobs.some((j) => j.status === 'pending' || j.status === 'generating'));
|
||||
|
||||
$effect(() => {
|
||||
if (!hasInFlight) return;
|
||||
const id = setInterval(() => {
|
||||
invalidateAll();
|
||||
}, 3000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
type Tab = 'jobs' | 'cache';
|
||||
let activeTab = $state<Tab>('jobs');
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function jobStatusColor(status: string) {
|
||||
if (status === 'done') return 'text-green-400';
|
||||
if (status === 'generating') return 'text-amber-400 animate-pulse';
|
||||
if (status === 'pending') return 'text-sky-400 animate-pulse';
|
||||
if (status === 'failed') return 'text-red-400';
|
||||
return 'text-zinc-300';
|
||||
}
|
||||
|
||||
function fmtDate(s: string) {
|
||||
@@ -22,71 +46,237 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Search ──────────────────────────────────────────────────────────────────
|
||||
let q = $state('');
|
||||
let filtered = $derived(
|
||||
q.trim()
|
||||
? entries.filter((e) => e.cache_key.toLowerCase().includes(q.toLowerCase().trim()))
|
||||
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`;
|
||||
}
|
||||
|
||||
// ── Audio jobs stats + filter ────────────────────────────────────────────────
|
||||
let jobsQ = $state('');
|
||||
let filteredJobs = $derived(
|
||||
jobsQ.trim()
|
||||
? jobs.filter(
|
||||
(j) =>
|
||||
j.slug.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
|
||||
j.voice.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 === 'generating').length
|
||||
});
|
||||
|
||||
// ── Audio cache filter ───────────────────────────────────────────────────────
|
||||
function parseCacheKey(key: string) {
|
||||
const parts = key.split('/');
|
||||
if (parts.length >= 3) {
|
||||
return { slug: parts[0], chapter: parts[1], voice: parts.slice(2).join('/') };
|
||||
}
|
||||
return { slug: key, chapter: '—', voice: '—' };
|
||||
}
|
||||
|
||||
let cacheQ = $state('');
|
||||
let filteredCache = $derived(
|
||||
cacheQ.trim()
|
||||
? entries.filter((e) => e.cache_key.toLowerCase().includes(cacheQ.toLowerCase().trim()))
|
||||
: entries
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Audio cache — libnovel admin</title>
|
||||
<title>Audio — libnovel admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Audio cache</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">{entries.length} cached audio file{entries.length !== 1 ? 's' : ''}</p>
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Audio</h1>
|
||||
<p class="text-zinc-400 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-red-400">{stats.failed} failed</span>
|
||||
{/if}
|
||||
{#if stats.inFlight > 0}
|
||||
· <span class="text-amber-400 animate-pulse">{stats.inFlight} in-flight</span>
|
||||
{/if}
|
||||
· {entries.length} cached file{entries.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<input
|
||||
type="search"
|
||||
bind:value={q}
|
||||
placeholder="Filter by slug, chapter or voice…"
|
||||
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 bg-zinc-800 rounded-lg p-1 w-fit border border-zinc-700">
|
||||
<button
|
||||
onclick={() => (activeTab = 'jobs')}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{activeTab === 'jobs' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'}"
|
||||
>
|
||||
Jobs
|
||||
{#if stats.inFlight > 0}
|
||||
<span class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-amber-400 text-zinc-900 text-[10px] font-bold">
|
||||
{stats.inFlight}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (activeTab = 'cache')}
|
||||
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
|
||||
{activeTab === 'cache' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'}"
|
||||
>
|
||||
Cache
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="text-zinc-500 text-sm py-8 text-center">
|
||||
{q.trim() ? 'No results.' : 'Audio cache is empty.'}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-xl border border-zinc-700">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Book</th>
|
||||
<th class="px-4 py-3 text-left">Chapter</th>
|
||||
<th class="px-4 py-3 text-left">Voice</th>
|
||||
<th class="px-4 py-3 text-left">Filename</th>
|
||||
<th class="px-4 py-3 text-left">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-700/50">
|
||||
{#each filtered as entry}
|
||||
{@const parts = parseKey(entry.cache_key)}
|
||||
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
|
||||
<td class="px-4 py-3 text-zinc-200 font-medium">
|
||||
<a
|
||||
href="/books/{parts.slug}"
|
||||
class="hover:text-amber-400 transition-colors"
|
||||
>
|
||||
{parts.slug}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-400">{parts.chapter}</td>
|
||||
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{parts.voice}</td>
|
||||
<td class="px-4 py-3 text-zinc-500 font-mono text-xs truncate max-w-[14rem]" title={entry.filename}>
|
||||
{entry.filename}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-400">{fmtDate(entry.updated)}</td>
|
||||
<!-- ── Audio Jobs tab ─────────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'jobs'}
|
||||
<input
|
||||
type="search"
|
||||
bind:value={jobsQ}
|
||||
placeholder="Filter by slug, voice or status…"
|
||||
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
|
||||
{#if filteredJobs.length === 0}
|
||||
<p class="text-zinc-500 text-sm py-8 text-center">
|
||||
{jobsQ.trim() ? 'No matching jobs.' : 'No audio jobs yet.'}
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-zinc-800 text-zinc-400 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">Voice</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>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-700/50">
|
||||
{#each filteredJobs as job}
|
||||
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
|
||||
<td class="px-4 py-3 text-zinc-200 font-medium">
|
||||
<a href="/books/{job.slug}" class="hover:text-amber-400 transition-colors">{job.slug}</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-zinc-400">{job.chapter}</td>
|
||||
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{job.voice}</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-zinc-400 whitespace-nowrap">{fmtDate(job.started)}</td>
|
||||
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{duration(job.started, job.finished)}</td>
|
||||
</tr>
|
||||
{#if job.error_message}
|
||||
<tr class="bg-red-950/20">
|
||||
<td colspan="6" class="px-4 py-2 text-xs text-red-400 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-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<a href="/books/{job.slug}" class="text-zinc-200 font-medium hover:text-amber-400 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-zinc-500">Chapter</span><span class="text-zinc-400 text-right">{job.chapter}</span>
|
||||
<span class="text-zinc-500">Voice</span><span class="text-zinc-400 font-mono text-right truncate">{job.voice}</span>
|
||||
<span class="text-zinc-500">Started</span><span class="text-zinc-400 text-right">{fmtDate(job.started)}</span>
|
||||
<span class="text-zinc-500">Duration</span><span class="text-zinc-400 text-right">{duration(job.started, job.finished)}</span>
|
||||
</div>
|
||||
{#if job.error_message}
|
||||
<p class="text-xs text-red-400 font-mono break-all">{job.error_message}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- ── Audio Cache tab ───────────────────────────────────────────────────── -->
|
||||
{#if activeTab === 'cache'}
|
||||
<input
|
||||
type="search"
|
||||
bind:value={cacheQ}
|
||||
placeholder="Filter by slug, chapter or voice…"
|
||||
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
|
||||
{#if filteredCache.length === 0}
|
||||
<p class="text-zinc-500 text-sm py-8 text-center">
|
||||
{cacheQ.trim() ? 'No results.' : 'Audio cache is empty.'}
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Book</th>
|
||||
<th class="px-4 py-3 text-left">Chapter</th>
|
||||
<th class="px-4 py-3 text-left">Voice</th>
|
||||
<th class="px-4 py-3 text-left">Filename</th>
|
||||
<th class="px-4 py-3 text-left">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-700/50">
|
||||
{#each filteredCache as entry}
|
||||
{@const parts = parseCacheKey(entry.cache_key)}
|
||||
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
|
||||
<td class="px-4 py-3 text-zinc-200 font-medium">
|
||||
<a href="/books/{parts.slug}" class="hover:text-amber-400 transition-colors">{parts.slug}</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-400">{parts.chapter}</td>
|
||||
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{parts.voice}</td>
|
||||
<td class="px-4 py-3 text-zinc-500 font-mono text-xs truncate max-w-[14rem]" title={entry.filename}>
|
||||
{entry.filename}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(entry.updated)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div class="sm:hidden space-y-3">
|
||||
{#each filteredCache as entry}
|
||||
{@const parts = parseCacheKey(entry.cache_key)}
|
||||
<div class="bg-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2">
|
||||
<a href="/books/{parts.slug}" class="text-zinc-200 font-medium hover:text-amber-400 transition-colors block truncate">
|
||||
{parts.slug}
|
||||
</a>
|
||||
<div class="grid grid-cols-2 gap-1 text-xs">
|
||||
<span class="text-zinc-500">Chapter</span><span class="text-zinc-400 text-right">{parts.chapter}</span>
|
||||
<span class="text-zinc-500">Voice</span><span class="text-zinc-400 font-mono text-right truncate">{parts.voice}</span>
|
||||
<span class="text-zinc-500">Updated</span><span class="text-zinc-400 text-right">{fmtDate(entry.updated)}</span>
|
||||
</div>
|
||||
{#if entry.filename}
|
||||
<p class="text-xs text-zinc-500 font-mono truncate" title={entry.filename}>{entry.filename}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { listScrapingTasks } from '$lib/server/pocketbase';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
if (locals.user?.role !== 'admin') {
|
||||
@@ -16,7 +14,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
log.warn('admin/scrape', 'failed to load tasks', { err: String(e) });
|
||||
return [];
|
||||
}),
|
||||
fetch(`${SCRAPER_URL}/api/scrape/status`).catch(() => null)
|
||||
backendFetch('/api/scrape/status').catch(() => null)
|
||||
]);
|
||||
|
||||
let running = false;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import type { ScrapingTask } from '$lib/server/pocketbase';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
// ── Live-poll status ────────────────────────────────────────────────────────
|
||||
let running = $state(data.running);
|
||||
let tasks = $state(data.tasks);
|
||||
let polling = $state(false);
|
||||
let running = $state(untrack(() => data.running));
|
||||
let tasks = $state(untrack(() => data.tasks));
|
||||
|
||||
// Poll every 5 s while a job is running
|
||||
$effect(() => {
|
||||
@@ -18,7 +19,6 @@
|
||||
const body = await res.json().catch(() => null);
|
||||
running = body?.running ?? false;
|
||||
if (!running) {
|
||||
// Refresh tasks list once job finishes
|
||||
await invalidateAll();
|
||||
}
|
||||
}
|
||||
@@ -32,28 +32,54 @@
|
||||
tasks = data.tasks;
|
||||
});
|
||||
|
||||
// ── Trigger scrape ──────────────────────────────────────────────────────────
|
||||
// ── 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 triggerScrape(url?: string) {
|
||||
if (running || scraping) return;
|
||||
async function triggerBookScrape(url: string) {
|
||||
if (running || scraping || !url.trim()) return;
|
||||
scraping = true;
|
||||
scrapeError = '';
|
||||
try {
|
||||
const body = url ? { url } : {};
|
||||
const res = await fetch('/api/scrape', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
body: JSON.stringify({ url: url.trim() })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
scrapeError = data.error ?? data.message ?? `Error ${res.status}`;
|
||||
const d = await res.json().catch(() => ({}));
|
||||
scrapeError = d.error ?? d.message ?? `Error ${res.status}`;
|
||||
} else {
|
||||
running = true;
|
||||
if (url) scrapeUrl = '';
|
||||
scrapeUrl = '';
|
||||
}
|
||||
} catch {
|
||||
scrapeError = 'Network error.';
|
||||
@@ -62,6 +88,108 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Table filter ────────────────────────────────────────────────────────────
|
||||
let q = $state('');
|
||||
let filtered = $derived(
|
||||
q.trim()
|
||||
? tasks.filter(
|
||||
(t: ScrapingTask) =>
|
||||
t.kind.toLowerCase().includes(q.toLowerCase()) ||
|
||||
t.status.toLowerCase().includes(q.toLowerCase()) ||
|
||||
(t.target_url ?? '').toLowerCase().includes(q.toLowerCase())
|
||||
)
|
||||
: tasks
|
||||
);
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
function statusColor(status: string) {
|
||||
if (status === 'done') return 'text-green-400';
|
||||
@@ -87,6 +215,16 @@
|
||||
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' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -94,6 +232,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Scrape tasks</h1>
|
||||
@@ -106,90 +245,269 @@
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trigger controls -->
|
||||
<div class="flex flex-wrap gap-3 items-start">
|
||||
<!-- Scrape controls -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Full catalogue -->
|
||||
<div class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-zinc-300">Scrape full catalogue</h2>
|
||||
<p class="text-xs text-zinc-500 mt-1">Re-crawls all novelfire.net pages and picks up new books.</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => triggerScrape()}
|
||||
disabled={running || scraping}
|
||||
class="px-4 py-2 rounded-lg bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors disabled:opacity-50"
|
||||
onclick={triggerCatalogueScrape}
|
||||
disabled={running || cataloguing}
|
||||
class="w-full px-4 py-2 rounded-lg bg-amber-600 text-zinc-900 font-semibold text-sm hover:bg-amber-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Full catalogue scrape
|
||||
{cataloguing ? 'Queuing…' : running ? 'Already running…' : 'Start catalogue scrape'}
|
||||
</button>
|
||||
{#if catalogueError}
|
||||
<p class="text-sm text-red-400">{catalogueError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Single book -->
|
||||
<div id="book-form" class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
|
||||
<h2 class="text-sm font-semibold text-zinc-300">Scrape a single book</h2>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={scrapeUrl}
|
||||
placeholder="https://novelfire.net/book/…"
|
||||
class="flex-1 min-w-0 bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<button
|
||||
onclick={() => triggerBookScrape(scrapeUrl)}
|
||||
disabled={!scrapeUrl.trim() || running || scraping}
|
||||
class="shrink-0 px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{scraping ? 'Queuing…' : 'Scrape'}
|
||||
</button>
|
||||
</div>
|
||||
{#if scrapeError}
|
||||
<p class="text-sm text-red-400">{scrapeError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Range scrape -->
|
||||
<div id="range-form" class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
|
||||
<h2 class="text-sm font-semibold text-zinc-300">Scrape chapter range</h2>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={rangeUrl}
|
||||
placeholder="https://novelfire.net/book/…"
|
||||
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={rangeFrom}
|
||||
min="1"
|
||||
placeholder="From ch."
|
||||
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={rangeTo}
|
||||
min="1"
|
||||
placeholder="To ch. (opt)"
|
||||
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<button
|
||||
onclick={triggerRangeScrape}
|
||||
disabled={!rangeUrl.trim() || rangeFrom === null || running || ranging}
|
||||
class="shrink-0 px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{ranging ? 'Queuing…' : 'Go'}
|
||||
</button>
|
||||
</div>
|
||||
{#if rangeError}
|
||||
<p class="text-sm text-red-400">{rangeError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Single book scrape -->
|
||||
<!-- Quick-scrape genre links -->
|
||||
<div class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
|
||||
<h2 class="text-sm font-semibold text-zinc-300">Scrape a single book</h2>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
bind:value={scrapeUrl}
|
||||
placeholder="https://novelfire.net/book/..."
|
||||
class="flex-1 bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
<button
|
||||
onclick={() => triggerScrape(scrapeUrl.trim() || undefined)}
|
||||
disabled={!scrapeUrl.trim() || running || scraping}
|
||||
class="px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-50"
|
||||
<h2 class="text-sm font-semibold text-zinc-300">Quick genre refresh</h2>
|
||||
<p class="text-xs text-zinc-500">Paste one of these into the single-book scraper to re-index a genre, or use them as starting points for range scrapes.</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each quickScrapes as qs}
|
||||
<button
|
||||
onclick={() => { scrapeUrl = qs.url; }}
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-300 border border-zinc-600 hover:border-amber-400/60 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
{qs.label}
|
||||
</button>
|
||||
{/each}
|
||||
<a
|
||||
href="https://novelfire.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700/50 text-zinc-400 border border-zinc-600/50 hover:text-amber-300 hover:border-amber-400/40 transition-colors"
|
||||
>
|
||||
Scrape
|
||||
</button>
|
||||
Browse novelfire.net ↗
|
||||
</a>
|
||||
</div>
|
||||
{#if scrapeError}
|
||||
<p class="text-sm text-red-400">{scrapeError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tasks table -->
|
||||
{#if tasks.length === 0}
|
||||
<p class="text-zinc-500 text-sm py-8 text-center">No scrape tasks yet.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-xl border border-zinc-700">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Kind</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-700/50">
|
||||
{#each tasks as task}
|
||||
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
|
||||
<td class="px-4 py-3 font-mono text-xs text-zinc-300">
|
||||
{task.kind}
|
||||
{#if task.target_url}
|
||||
<br />
|
||||
<span class="text-zinc-500 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-zinc-300">{task.books_found ?? 0}</td>
|
||||
<td class="px-4 py-3 text-right text-zinc-300">{task.chapters_scraped ?? 0}</td>
|
||||
<td class="px-4 py-3 text-right text-zinc-400">{task.chapters_skipped ?? 0}</td>
|
||||
<td class="px-4 py-3 text-right {task.errors > 0 ? 'text-red-400' : 'text-zinc-400'}">{task.errors ?? 0}</td>
|
||||
<td class="px-4 py-3 text-zinc-400">{fmtDate(task.started)}</td>
|
||||
<td class="px-4 py-3 text-zinc-400">{duration(task.started, task.finished)}</td>
|
||||
</tr>
|
||||
{#if task.error_message}
|
||||
<tr class="bg-red-950/20">
|
||||
<td colspan="8" class="px-4 py-2 text-xs text-red-400 font-mono">{task.error_message}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h2 class="text-lg font-semibold text-zinc-100 flex-1">Task history</h2>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={q}
|
||||
placeholder="Filter by kind, status or URL…"
|
||||
class="w-full max-w-xs bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="text-zinc-500 text-sm py-8 text-center">
|
||||
{q.trim() ? 'No matching tasks.' : 'No scrape tasks yet.'}
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-zinc-800 text-zinc-400 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-zinc-700/50">
|
||||
{#each filtered as task}
|
||||
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
|
||||
<td class="px-4 py-3 font-mono text-xs text-zinc-300">
|
||||
{task.kind}
|
||||
{#if task.target_url}
|
||||
<br />
|
||||
<span class="text-zinc-500 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-zinc-300">{task.books_found ?? 0}</td>
|
||||
<td class="px-4 py-3 text-right text-zinc-300">{task.chapters_scraped ?? 0}</td>
|
||||
<td class="px-4 py-3 text-right text-zinc-400">{task.chapters_skipped ?? 0}</td>
|
||||
<td class="px-4 py-3 text-right {task.errors > 0 ? 'text-red-400' : 'text-zinc-400'}">{task.errors ?? 0}</td>
|
||||
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(task.started)}</td>
|
||||
<td class="px-4 py-3 text-zinc-400 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-zinc-700 text-zinc-300 hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancellingIds.has(task.id) ? 'Cancelling…' : '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-red-400 mt-1 w-full">{cancelErrors[task.id]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{#if task.error_message}
|
||||
<tr class="bg-red-950/20">
|
||||
<td colspan="9" class="px-4 py-2 text-xs text-red-400 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-zinc-900 rounded-xl border border-zinc-700 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-zinc-300">{task.kind}</span>
|
||||
{#if task.target_url}
|
||||
<p class="text-xs text-zinc-500 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-zinc-500">Books</span><span class="text-zinc-300 text-right">{task.books_found ?? 0}</span>
|
||||
<span class="text-zinc-500">Chapters</span><span class="text-zinc-300 text-right">{task.chapters_scraped ?? 0}</span>
|
||||
<span class="text-zinc-500">Skipped</span><span class="text-zinc-400 text-right">{task.chapters_skipped ?? 0}</span>
|
||||
<span class="text-zinc-500">Errors</span><span class="{task.errors > 0 ? 'text-red-400' : 'text-zinc-400'} text-right">{task.errors ?? 0}</span>
|
||||
<span class="text-zinc-500">Started</span><span class="text-zinc-400 text-right">{fmtDate(task.started)}</span>
|
||||
<span class="text-zinc-500">Duration</span><span class="text-zinc-400 text-right">{duration(task.started, task.finished)}</span>
|
||||
</div>
|
||||
{#if task.error_message}
|
||||
<p class="text-xs text-red-400 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-zinc-700 text-zinc-300 hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancellingIds.has(task.id) ? 'Cancelling…' : '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-red-400 w-full">{cancelErrors[task.id]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user