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:
@@ -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'
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
←
|
||||
{#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"
|
||||
>
|
||||
→
|
||||
</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"
|
||||
>
|
||||
←
|
||||
</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"
|
||||
>
|
||||
→
|
||||
</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}
|
||||
|
||||
Reference in New Issue
Block a user