|
|
|
|
@@ -374,7 +374,9 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// If the normal path returned no content, fall back to live preview scrape
|
|
|
|
|
if (!data.isPreview && !data.html) {
|
|
|
|
|
// (but only when contentMissing is false — if the file is genuinely absent
|
|
|
|
|
// from storage the preview scrape won't help either and admins can re-queue)
|
|
|
|
|
if (!data.isPreview && !data.html && !data.contentMissing) {
|
|
|
|
|
fetchingContent = true;
|
|
|
|
|
(async () => {
|
|
|
|
|
try {
|
|
|
|
|
@@ -406,6 +408,62 @@
|
|
|
|
|
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ── Admin chapter tools ────────────────────────────────────────────────────
|
|
|
|
|
let adminOpen = $state(false);
|
|
|
|
|
let scrapeStatus = $state<'idle' | 'busy' | 'queued' | 'error'>('idle');
|
|
|
|
|
let scrapeTaskId = $state('');
|
|
|
|
|
let scrapeProgress = $state('');
|
|
|
|
|
let pollTimer = 0;
|
|
|
|
|
|
|
|
|
|
async function scrapeThisChapter() {
|
|
|
|
|
if (scrapeStatus === 'busy' || !data.book.source_url) return;
|
|
|
|
|
scrapeStatus = 'busy';
|
|
|
|
|
scrapeProgress = '';
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch('/api/scrape/range', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ url: data.book.source_url, from: data.chapter.number, to: data.chapter.number })
|
|
|
|
|
});
|
|
|
|
|
const d = await res.json().catch(() => ({}));
|
|
|
|
|
if (res.ok) {
|
|
|
|
|
scrapeStatus = 'queued';
|
|
|
|
|
scrapeTaskId = d.task_id ?? '';
|
|
|
|
|
if (scrapeTaskId) startPollTask(scrapeTaskId);
|
|
|
|
|
} else if (res.status === 409) {
|
|
|
|
|
scrapeStatus = 'error';
|
|
|
|
|
scrapeProgress = 'A scrape job is already running — try again shortly.';
|
|
|
|
|
} else {
|
|
|
|
|
scrapeStatus = 'error';
|
|
|
|
|
scrapeProgress = d.message ?? `Error ${res.status}`;
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
scrapeStatus = 'error';
|
|
|
|
|
scrapeProgress = String(e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startPollTask(id: string) {
|
|
|
|
|
clearTimeout(pollTimer);
|
|
|
|
|
pollTimer = setTimeout(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/scrape/task/${id}`);
|
|
|
|
|
if (!res.ok) return;
|
|
|
|
|
const d = await res.json() as { status: string; chapters_scraped?: number; error_message?: string };
|
|
|
|
|
if (d.status === 'done') {
|
|
|
|
|
scrapeProgress = `Done — ${d.chapters_scraped ?? 0} chapter(s) scraped. Reload to see content.`;
|
|
|
|
|
scrapeStatus = 'queued';
|
|
|
|
|
} else if (d.status === 'failed') {
|
|
|
|
|
scrapeProgress = `Failed: ${d.error_message ?? 'unknown error'}`;
|
|
|
|
|
scrapeStatus = 'error';
|
|
|
|
|
} else {
|
|
|
|
|
scrapeProgress = `Status: ${d.status}${d.chapters_scraped ? ` (${d.chapters_scraped} done)` : ''}`;
|
|
|
|
|
startPollTask(id); // keep polling
|
|
|
|
|
}
|
|
|
|
|
} catch { /* ignore */ }
|
|
|
|
|
}, 3000) as unknown as number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Strip scraper artifacts from chapter titles:
|
|
|
|
|
// - Leading digit(s) prefixed before "Chapter" (e.g. "6Chapter 6 : ...")
|
|
|
|
|
// - Everything after the first newline (often includes a scraped date)
|
|
|
|
|
@@ -717,8 +775,115 @@
|
|
|
|
|
{m.reader_fetching_chapter()}
|
|
|
|
|
</div>
|
|
|
|
|
{:else if !html}
|
|
|
|
|
<div class="text-(--color-muted) text-center py-16">
|
|
|
|
|
<p>{fetchError || m.reader_audio_error()}</p>
|
|
|
|
|
<!-- ── Content unavailable ─────────────────────────────────────────── -->
|
|
|
|
|
<div class="mt-8 rounded-xl border border-(--color-border) bg-(--color-surface-2) overflow-hidden">
|
|
|
|
|
<!-- Banner — visible to all users -->
|
|
|
|
|
<div class="flex items-start gap-4 p-6">
|
|
|
|
|
<div class="w-10 h-10 rounded-full bg-(--color-surface-3) flex items-center justify-center shrink-0 mt-0.5">
|
|
|
|
|
<svg class="w-5 h-5 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p class="font-semibold text-(--color-text) mb-1">Chapter content not available</p>
|
|
|
|
|
<p class="text-sm text-(--color-muted)">
|
|
|
|
|
{#if data.contentMissing}
|
|
|
|
|
This chapter exists in the index but its content hasn't been stored yet.
|
|
|
|
|
{#if !data.isAdmin}It should appear soon — try refreshing in a few minutes.{/if}
|
|
|
|
|
{:else}
|
|
|
|
|
{fetchError || 'Could not load this chapter. Please try again.'}
|
|
|
|
|
{/if}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Admin toolbar — only visible to admins -->
|
|
|
|
|
{#if data.isAdmin}
|
|
|
|
|
<div class="border-t border-(--color-border)">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onclick={() => (adminOpen = !adminOpen)}
|
|
|
|
|
class="w-full flex items-center gap-2 px-5 py-3 text-xs font-semibold text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)/40 transition-colors text-left"
|
|
|
|
|
>
|
|
|
|
|
<svg class="w-3.5 h-3.5 shrink-0 text-(--color-brand)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
Admin Tools
|
|
|
|
|
<svg class="w-3 h-3 ml-auto transition-transform {adminOpen ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{#if adminOpen}
|
|
|
|
|
<div class="px-5 pb-5 flex flex-col gap-4 border-t border-(--color-border)">
|
|
|
|
|
|
|
|
|
|
<!-- Source URL info -->
|
|
|
|
|
{#if data.book.source_url}
|
|
|
|
|
<div class="mt-3">
|
|
|
|
|
<p class="text-xs text-(--color-muted) mb-1">Source URL</p>
|
|
|
|
|
<a href={data.book.source_url} target="_blank" rel="noopener noreferrer"
|
|
|
|
|
class="text-xs text-(--color-brand) hover:underline break-all">
|
|
|
|
|
{data.book.source_url}
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- Scrape this chapter -->
|
|
|
|
|
<div class="flex flex-col gap-2">
|
|
|
|
|
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Scrape Chapter {data.chapter.number}</p>
|
|
|
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onclick={scrapeThisChapter}
|
|
|
|
|
disabled={scrapeStatus === 'busy' || !data.book.source_url}
|
|
|
|
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-xs hover:bg-(--color-brand-dim) disabled:opacity-50 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{#if scrapeStatus === 'busy'}
|
|
|
|
|
<svg class="w-3.5 h-3.5 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>
|
|
|
|
|
Scraping…
|
|
|
|
|
{:else}
|
|
|
|
|
<svg class="w-3.5 h-3.5" 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>
|
|
|
|
|
Re-scrape ch.{data.chapter.number}
|
|
|
|
|
{/if}
|
|
|
|
|
</button>
|
|
|
|
|
{#if !data.book.source_url}
|
|
|
|
|
<span class="text-xs text-(--color-muted)">No source URL on this book.</span>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{#if scrapeProgress}
|
|
|
|
|
<p class="text-xs {scrapeStatus === 'error' ? 'text-red-400' : 'text-green-400'}">{scrapeProgress}</p>
|
|
|
|
|
{:else if scrapeStatus === 'queued'}
|
|
|
|
|
<p class="text-xs text-(--color-muted)">Queued — polling for completion…</p>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Quick links -->
|
|
|
|
|
<div class="flex flex-col gap-1.5">
|
|
|
|
|
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Quick links</p>
|
|
|
|
|
<div class="flex flex-wrap gap-2">
|
|
|
|
|
<a href="/admin/scrape" class="text-xs px-2.5 py-1 rounded-md bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors">
|
|
|
|
|
Scrape dashboard
|
|
|
|
|
</a>
|
|
|
|
|
<a href="/books/{data.book.slug}" class="text-xs px-2.5 py-1 rounded-md bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors">
|
|
|
|
|
Book page
|
|
|
|
|
</a>
|
|
|
|
|
<a href="https://pb.libnovel.cc/_/" target="_blank" rel="noopener noreferrer"
|
|
|
|
|
class="text-xs px-2.5 py-1 rounded-md bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-text) transition-colors">
|
|
|
|
|
PocketBase ↗
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<!-- Chapter illustration hero (if generated, hidden in focus mode) -->
|
|
|
|
|
|