feat(ui): add admin Rescrape button to book detail page

Admins see a Rescrape button next to the chapter list header.
Clicking it POSTs to /api/scrape with the book's source_url,
then shows an inline status banner (queued / busy / error).
isAdmin is exposed from the server load; no new routes needed.
This commit is contained in:
Admin
2026-03-04 18:21:17 +05:00
parent 0eee2eedf3
commit 1e7f396b2d
2 changed files with 80 additions and 17 deletions

View File

@@ -29,6 +29,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
return {
book,
chapters,
lastChapter: progress?.chapter ?? null
lastChapter: progress?.chapter ?? null,
isAdmin: locals.user?.role === 'admin'
};
};

View File

@@ -21,6 +21,30 @@
const visibleChapters = $derived(
data.chapters.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
);
// ── Admin: rescrape ───────────────────────────────────────────────────────
let scraping = $state(false);
let scrapeResult = $state<'queued' | 'busy' | 'error' | ''>('');
async function rescrape() {
if (scraping || !data.book.source_url) return;
scraping = true;
scrapeResult = '';
try {
const res = await fetch('/api/scrape', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: data.book.source_url })
});
if (res.ok) scrapeResult = 'queued';
else if (res.status === 409) scrapeResult = 'busy';
else scrapeResult = 'error';
} catch {
scrapeResult = 'error';
} finally {
scraping = false;
}
}
</script>
<svelte:head>
@@ -86,27 +110,65 @@
<span class="text-zinc-500 font-normal text-sm ml-1">({data.chapters.length})</span>
</h2>
{#if totalPages > 1}
<div class="flex gap-2 items-center text-sm">
<div class="flex items-center gap-3">
{#if data.isAdmin && data.book.source_url}
<button
onclick={() => (page = Math.max(0, page - 1))}
disabled={page === 0}
class="px-2 py-1 rounded bg-zinc-700 text-zinc-300 disabled:opacity-40 hover:bg-zinc-600 transition-colors"
onclick={rescrape}
disabled={scraping}
class="px-3 py-1 rounded text-xs font-medium transition-colors flex items-center gap-1.5
{scraping
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
: 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600'}"
title="Re-scrape this book from source"
>
&larr;
{#if scraping}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Queuing…
{:else}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Rescrape
{/if}
</button>
<span class="text-zinc-400">{page + 1} / {totalPages}</span>
<button
onclick={() => (page = Math.min(totalPages - 1, page + 1))}
disabled={page === totalPages - 1}
class="px-2 py-1 rounded bg-zinc-700 text-zinc-300 disabled:opacity-40 hover:bg-zinc-600 transition-colors"
>
&rarr;
</button>
</div>
{/if}
{/if}
{#if totalPages > 1}
<div class="flex gap-2 items-center text-sm">
<button
onclick={() => (page = Math.max(0, page - 1))}
disabled={page === 0}
class="px-2 py-1 rounded bg-zinc-700 text-zinc-300 disabled:opacity-40 hover:bg-zinc-600 transition-colors"
>
&larr;
</button>
<span class="text-zinc-400">{page + 1} / {totalPages}</span>
<button
onclick={() => (page = Math.min(totalPages - 1, page + 1))}
disabled={page === totalPages - 1}
class="px-2 py-1 rounded bg-zinc-700 text-zinc-300 disabled:opacity-40 hover:bg-zinc-600 transition-colors"
>
&rarr;
</button>
</div>
{/if}
</div>
</div>
{#if scrapeResult}
<div class="mb-3 px-3 py-2 rounded text-xs font-medium
{scrapeResult === 'queued' ? 'bg-green-900/40 text-green-300 border border-green-800' :
scrapeResult === 'busy' ? 'bg-amber-900/40 text-amber-300 border border-amber-800' :
'bg-red-900/40 text-red-300 border border-red-800'}">
{scrapeResult === 'queued' ? 'Rescrape queued — running in background.' :
scrapeResult === 'busy' ? 'Scraper is busy with another job. Try again shortly.' :
'Failed to queue rescrape. Check server logs.'}
</div>
{/if}
{#if data.chapters.length === 0}
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
{:else}