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 {
|
return {
|
||||||
book,
|
book,
|
||||||
chapters,
|
chapters,
|
||||||
lastChapter: progress?.chapter ?? null
|
lastChapter: progress?.chapter ?? null,
|
||||||
|
isAdmin: locals.user?.role === 'admin'
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,30 @@
|
|||||||
const visibleChapters = $derived(
|
const visibleChapters = $derived(
|
||||||
data.chapters.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -86,27 +110,65 @@
|
|||||||
<span class="text-zinc-500 font-normal text-sm ml-1">({data.chapters.length})</span>
|
<span class="text-zinc-500 font-normal text-sm ml-1">({data.chapters.length})</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{#if totalPages > 1}
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex gap-2 items-center text-sm">
|
{#if data.isAdmin && data.book.source_url}
|
||||||
<button
|
<button
|
||||||
onclick={() => (page = Math.max(0, page - 1))}
|
onclick={rescrape}
|
||||||
disabled={page === 0}
|
disabled={scraping}
|
||||||
class="px-2 py-1 rounded bg-zinc-700 text-zinc-300 disabled:opacity-40 hover:bg-zinc-600 transition-colors"
|
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>
|
</button>
|
||||||
<span class="text-zinc-400">{page + 1} / {totalPages}</span>
|
{/if}
|
||||||
<button
|
|
||||||
onclick={() => (page = Math.min(totalPages - 1, page + 1))}
|
{#if totalPages > 1}
|
||||||
disabled={page === totalPages - 1}
|
<div class="flex gap-2 items-center text-sm">
|
||||||
class="px-2 py-1 rounded bg-zinc-700 text-zinc-300 disabled:opacity-40 hover:bg-zinc-600 transition-colors"
|
<button
|
||||||
>
|
onclick={() => (page = Math.max(0, page - 1))}
|
||||||
→
|
disabled={page === 0}
|
||||||
</button>
|
class="px-2 py-1 rounded bg-zinc-700 text-zinc-300 disabled:opacity-40 hover:bg-zinc-600 transition-colors"
|
||||||
</div>
|
>
|
||||||
{/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}
|
||||||
|
</div>
|
||||||
</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}
|
{#if data.chapters.length === 0}
|
||||||
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
|
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
Reference in New Issue
Block a user