feat: profile page, admin pages, infinite scroll on browse
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / Scraper / Build (pull_request) Successful in 16s

- Add /profile page with reading settings (voice, speed, auto-next) and password change form
- Add /admin/scrape page showing scraping task history with live status polling and trigger controls
- Add /admin/audio page showing audio cache entries with client-side search filter
- Add changePassword(), listAudioCache(), listScrapingTasks() to pocketbase.ts
- Add /api/admin/scrape and /api/browse-page server-side proxy routes
- Replace browse page pagination with IntersectionObserver infinite scroll
- Update nav: username becomes a /profile link; admin users see Scrape and Audio cache links
This commit is contained in:
Admin
2026-03-06 18:58:24 +05:00
parent 08d4718245
commit 8f0a2f7e92
11 changed files with 845 additions and 38 deletions

View File

@@ -0,0 +1,195 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// ── Live-poll status ────────────────────────────────────────────────────────
let running = $state(data.running);
let tasks = $state(data.tasks);
let polling = $state(false);
// 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) {
// Refresh tasks list once job finishes
await invalidateAll();
}
}
}, 5000);
return () => clearInterval(id);
});
// Keep local state in sync when server re-loads
$effect(() => {
running = data.running;
tasks = data.tasks;
});
// ── Trigger scrape ──────────────────────────────────────────────────────────
let scrapeUrl = $state('');
let scrapeError = $state('');
let scraping = $state(false);
async function triggerScrape(url?: string) {
if (running || scraping) 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)
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
scrapeError = data.error ?? data.message ?? `Error ${res.status}`;
} else {
running = true;
if (url) scrapeUrl = '';
}
} catch {
scrapeError = 'Network error.';
} finally {
scraping = false;
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function statusColor(status: string) {
if (status === 'done') return 'text-green-400';
if (status === 'running') return 'text-amber-400 animate-pulse';
if (status === 'failed') return 'text-red-400';
if (status === 'cancelled') return 'text-zinc-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`;
}
</script>
<svelte:head>
<title>Scrape tasks — libnovel admin</title>
</svelte:head>
<div class="space-y-8">
<div class="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 class="text-2xl font-bold text-zinc-100">Scrape tasks</h1>
<p class="text-zinc-400 text-sm mt-1">
Job status:
{#if running}
<span class="text-amber-400 font-medium animate-pulse">Running</span>
{:else}
<span class="text-green-400 font-medium">Idle</span>
{/if}
</p>
</div>
<!-- Trigger controls -->
<div class="flex flex-wrap gap-3 items-start">
<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"
>
Full catalogue scrape
</button>
</div>
</div>
<!-- Single book scrape -->
<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"
>
Scrape
</button>
</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>
{/if}
</div>