Some checks failed
CI / Scraper / Test (push) Successful in 10s
CI / UI / Build (push) Failing after 9s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / UI / Build (pull_request) Failing after 7s
CI / UI / Docker Push (push) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Lint (push) Successful in 28s
CI / Scraper / Test (pull_request) Successful in 20s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 39s
iOS CI / Build (push) Successful in 2m16s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Successful in 5m35s
iOS CI / Test (pull_request) Successful in 5m50s
- Batch-resolve avatar presign URLs server-side in GET /api/comments/[slug]; returns avatarUrls map alongside comments and myVotes - CommentsSection.svelte: show avatar image or initials fallback (24px top-level, 20px replies) next to each comment/reply username - iOS CommentsResponse gains avatarUrls field; CommentsViewModel stores and populates it on load; CommentRow renders AsyncImage with initials fallback - Also includes: comment replies (1-level nesting), delete, sort (Top/New), parent_id schema migration, and AvatarCropModal cropperjs fix
532 lines
21 KiB
Svelte
532 lines
21 KiB
Svelte
<script lang="ts">
|
||
import { onMount } from 'svelte';
|
||
import { invalidateAll } from '$app/navigation';
|
||
import type { PageData } from './$types';
|
||
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||
|
||
let { data }: { data: PageData } = $props();
|
||
|
||
// ── Save / unsave ─────────────────────────────────────────────────────────
|
||
let saved = $state(data.saved);
|
||
let saving = $state(false);
|
||
|
||
async function toggleSave() {
|
||
if (saving) return;
|
||
saving = true;
|
||
try {
|
||
const method = saved ? 'DELETE' : 'POST';
|
||
const res = await fetch(`/api/library/${encodeURIComponent(data.book.slug)}`, { method });
|
||
if (res.ok) saved = !saved;
|
||
} finally {
|
||
saving = false;
|
||
}
|
||
}
|
||
|
||
function parseGenres(genres: string[] | string): string[] {
|
||
if (Array.isArray(genres)) return genres;
|
||
try {
|
||
return JSON.parse(genres);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
const genres = $derived(parseGenres(data.book.genres));
|
||
|
||
// Paginate chapter list — 50 on mobile, 100 on sm+ (≥640px)
|
||
let pageSize = $state(50);
|
||
|
||
onMount(() => {
|
||
const mq = window.matchMedia('(min-width: 640px)');
|
||
pageSize = mq.matches ? 100 : 50;
|
||
const handler = (e: MediaQueryListEvent) => { pageSize = e.matches ? 100 : 50; };
|
||
mq.addEventListener('change', handler);
|
||
return () => mq.removeEventListener('change', handler);
|
||
});
|
||
|
||
// Start on the page that contains the current chapter (if any)
|
||
function pageForChapter(chapterNum: number | null, list: typeof chapterList): number {
|
||
if (!chapterNum || list.length === 0) return 0;
|
||
const idx = list.findIndex((c) => c.number === chapterNum);
|
||
if (idx === -1) return 0;
|
||
return Math.floor(idx / pageSize);
|
||
}
|
||
|
||
let page = $state(pageForChapter(data.lastChapter, data.inLib ? data.chapters : (data.previewChapters ?? [])));
|
||
|
||
// Use preview chapters if the book is not in the library
|
||
const chapterList = $derived(
|
||
data.inLib
|
||
? data.chapters
|
||
: (data.previewChapters ?? [])
|
||
);
|
||
const totalPages = $derived(Math.ceil(chapterList.length / pageSize));
|
||
const visibleChapters = $derived(
|
||
chapterList.slice(page * pageSize, (page + 1) * pageSize)
|
||
);
|
||
|
||
// ── Chapter list polling ──────────────────────────────────────────────────
|
||
// When the book was just added to the library via preview (inLib=true but
|
||
// no chapters yet), poll until the background WriteChapterRefs completes.
|
||
let pollingChapters = $state(data.inLib && data.chapters.length === 0);
|
||
|
||
onMount(() => {
|
||
if (!pollingChapters) return;
|
||
let attempts = 0;
|
||
const MAX_ATTEMPTS = 20; // ~10 seconds
|
||
const timer = setInterval(async () => {
|
||
attempts++;
|
||
await invalidateAll();
|
||
if (data.chapters.length > 0 || attempts >= MAX_ATTEMPTS) {
|
||
pollingChapters = false;
|
||
clearInterval(timer);
|
||
}
|
||
}, 500);
|
||
return () => clearInterval(timer);
|
||
});
|
||
|
||
// ── 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;
|
||
}
|
||
}
|
||
|
||
// ── Admin: scrape range ───────────────────────────────────────────────────
|
||
let rangeFrom = $state('');
|
||
let rangeTo = $state('');
|
||
let rangeScraping = $state(false);
|
||
let rangeResult = $state<'queued' | 'busy' | 'error' | ''>('');
|
||
|
||
async function scrapeRange() {
|
||
if (rangeScraping || !data.book.source_url) return;
|
||
const from = parseInt(rangeFrom, 10);
|
||
const to = parseInt(rangeTo, 10);
|
||
if (!from || from < 1) return;
|
||
rangeScraping = true;
|
||
rangeResult = '';
|
||
try {
|
||
const res = await fetch('/api/scrape/range', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url: data.book.source_url, from, to: to || undefined })
|
||
});
|
||
if (res.ok) rangeResult = 'queued';
|
||
else if (res.status === 409) rangeResult = 'busy';
|
||
else rangeResult = 'error';
|
||
} catch {
|
||
rangeResult = 'error';
|
||
} finally {
|
||
rangeScraping = false;
|
||
}
|
||
}
|
||
|
||
async function scrapeFromChapter(n: number) {
|
||
if (rangeScraping || !data.book.source_url) return;
|
||
rangeScraping = true;
|
||
rangeResult = '';
|
||
try {
|
||
const res = await fetch('/api/scrape/range', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url: data.book.source_url, from: n })
|
||
});
|
||
if (res.ok) rangeResult = 'queued';
|
||
else if (res.status === 409) rangeResult = 'busy';
|
||
else rangeResult = 'error';
|
||
} catch {
|
||
rangeResult = 'error';
|
||
} finally {
|
||
rangeScraping = false;
|
||
}
|
||
}
|
||
|
||
// ── Summary expand/collapse ───────────────────────────────────────────────
|
||
let summaryExpanded = $state(false);
|
||
|
||
// ── Admin panel expand/collapse ───────────────────────────────────────────
|
||
let adminOpen = $state(false);
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>{data.book.title} — libnovel</title>
|
||
</svelte:head>
|
||
|
||
<!-- ═══════════════════════════════════════════════════════════════ Hero ══ -->
|
||
<div class="relative rounded-xl overflow-hidden mb-8">
|
||
<!-- Blurred cover background -->
|
||
{#if data.book.cover}
|
||
<div
|
||
class="absolute inset-0 bg-cover bg-center scale-110"
|
||
style="background-image: url('{data.book.cover}'); filter: blur(24px); opacity: 0.18;"
|
||
aria-hidden="true"
|
||
></div>
|
||
{/if}
|
||
<div class="absolute inset-0 bg-gradient-to-b from-zinc-900/60 to-zinc-900/95 pointer-events-none" aria-hidden="true"></div>
|
||
|
||
<div class="relative flex flex-col p-5 sm:p-7 gap-4">
|
||
<!-- Cover + meta row -->
|
||
<div class="flex gap-5 sm:gap-8">
|
||
<!-- Cover image -->
|
||
{#if data.book.cover}
|
||
<img
|
||
src={data.book.cover}
|
||
alt={data.book.title}
|
||
class="w-28 sm:w-48 rounded-lg object-cover flex-shrink-0 border border-zinc-700 shadow-xl self-start"
|
||
/>
|
||
{/if}
|
||
|
||
<!-- Meta -->
|
||
<div class="flex flex-col gap-2 min-w-0 flex-1">
|
||
<!-- Title + "not in library" badge -->
|
||
<div class="flex items-start gap-2 flex-wrap">
|
||
<h1 class="text-xl sm:text-3xl font-bold text-zinc-100 leading-tight">{data.book.title}</h1>
|
||
{#if !data.inLib}
|
||
<span
|
||
class="mt-1 text-xs px-2 py-0.5 rounded-full bg-zinc-700 text-zinc-400 border border-zinc-600 shrink-0"
|
||
title="This book was fetched live from the source and is not yet in your library"
|
||
>
|
||
not in library
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Author -->
|
||
{#if data.book.author}
|
||
<p class="text-zinc-400 text-sm">{data.book.author}</p>
|
||
{/if}
|
||
|
||
<!-- Status + genres -->
|
||
<div class="flex flex-wrap gap-1.5 mt-0.5">
|
||
{#if data.book.status}
|
||
<span class="text-xs px-2 py-0.5 rounded bg-zinc-700 text-zinc-300 border border-zinc-600">{data.book.status}</span>
|
||
{/if}
|
||
{#each genres as genre}
|
||
<span class="text-xs px-2 py-0.5 rounded bg-zinc-800 text-zinc-400 border border-zinc-700">{genre}</span>
|
||
{/each}
|
||
</div>
|
||
|
||
<!-- Summary with expand toggle -->
|
||
{#if data.book.summary}
|
||
<div class="mt-1">
|
||
<p class="text-zinc-400 text-sm leading-relaxed break-words {summaryExpanded ? '' : 'line-clamp-3'}">
|
||
{data.book.summary}
|
||
</p>
|
||
{#if data.book.summary.length > 220}
|
||
<button
|
||
onclick={() => (summaryExpanded = !summaryExpanded)}
|
||
class="text-xs text-amber-400/70 hover:text-amber-400 mt-1 transition-colors"
|
||
>
|
||
{summaryExpanded ? 'Less' : 'More'}
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- CTA buttons — desktop only (hidden on mobile, shown below on mobile) -->
|
||
<div class="hidden sm:flex gap-2 mt-3 items-center flex-wrap">
|
||
{#if data.lastChapter}
|
||
<a
|
||
href="/books/{data.book.slug}/chapters/{data.lastChapter}"
|
||
class="px-5 py-2 bg-amber-400 text-zinc-900 font-semibold rounded-lg text-sm hover:bg-amber-300 transition-colors shadow"
|
||
>
|
||
Continue ch.{data.lastChapter}
|
||
</a>
|
||
{/if}
|
||
{#if chapterList.length > 0}
|
||
<a
|
||
href="/books/{data.book.slug}/chapters/1"
|
||
class="px-4 py-2 rounded-lg text-sm font-semibold transition-colors
|
||
{data.lastChapter
|
||
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
|
||
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300 shadow'}"
|
||
>
|
||
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
|
||
</a>
|
||
{/if}
|
||
{#if data.inLib}
|
||
<button
|
||
onclick={toggleSave}
|
||
disabled={saving}
|
||
title={saved ? 'Remove from library' : 'Add to library'}
|
||
class="flex items-center justify-center w-9 h-9 rounded-lg border transition-colors disabled:opacity-50
|
||
{saved
|
||
? 'bg-amber-400/20 text-amber-300 border-amber-400/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
|
||
: 'bg-zinc-700 text-zinc-400 border-zinc-600 hover:bg-zinc-600 hover:text-zinc-100'}"
|
||
>
|
||
{#if saving}
|
||
<svg class="w-4 h-4 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>
|
||
{:else if saved}
|
||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
|
||
</svg>
|
||
{:else}
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
|
||
</svg>
|
||
{/if}
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CTA buttons — mobile only, full-width row below cover+meta -->
|
||
<div class="flex sm:hidden gap-2 items-center">
|
||
{#if data.lastChapter}
|
||
<a
|
||
href="/books/{data.book.slug}/chapters/{data.lastChapter}"
|
||
class="flex-1 text-center px-4 py-2.5 bg-amber-400 text-zinc-900 font-semibold rounded-lg text-sm hover:bg-amber-300 transition-colors shadow"
|
||
>
|
||
Continue ch.{data.lastChapter}
|
||
</a>
|
||
{/if}
|
||
{#if chapterList.length > 0}
|
||
<a
|
||
href="/books/{data.book.slug}/chapters/1"
|
||
class="flex-1 text-center px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors
|
||
{data.lastChapter
|
||
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
|
||
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300 shadow'}"
|
||
>
|
||
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
|
||
</a>
|
||
{/if}
|
||
{#if data.inLib}
|
||
<button
|
||
onclick={toggleSave}
|
||
disabled={saving}
|
||
title={saved ? 'Remove from library' : 'Add to library'}
|
||
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border transition-colors disabled:opacity-50
|
||
{saved
|
||
? 'bg-amber-400/20 text-amber-300 border-amber-400/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
|
||
: 'bg-zinc-700 text-zinc-400 border-zinc-600 hover:bg-zinc-600 hover:text-zinc-100'}"
|
||
>
|
||
{#if saving}
|
||
<svg class="w-4 h-4 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>
|
||
{:else if saved}
|
||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||
<path d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
|
||
</svg>
|
||
{:else}
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
|
||
</svg>
|
||
{/if}
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══════════════════════════════════════════════════ Chapter list ══ -->
|
||
<div>
|
||
<!-- Header row: title + pagination -->
|
||
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||
<h2 class="text-base font-semibold text-zinc-200">
|
||
Chapters
|
||
{#if chapterList.length > 0}
|
||
<span class="text-zinc-500 font-normal text-sm ml-1">({chapterList.length})</span>
|
||
{/if}
|
||
</h2>
|
||
|
||
{#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-800 text-zinc-300 disabled:opacity-40 hover:bg-zinc-700 transition-colors"
|
||
>
|
||
←
|
||
</button>
|
||
<span class="text-zinc-500 text-xs tabular-nums">
|
||
{page * pageSize + 1}–{Math.min((page + 1) * pageSize, chapterList.length)} of {chapterList.length}
|
||
</span>
|
||
<button
|
||
onclick={() => (page = Math.min(totalPages - 1, page + 1))}
|
||
disabled={page === totalPages - 1}
|
||
class="px-2 py-1 rounded bg-zinc-800 text-zinc-300 disabled:opacity-40 hover:bg-zinc-700 transition-colors"
|
||
>
|
||
→
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Chapter rows -->
|
||
{#if pollingChapters}
|
||
<div class="flex items-center gap-3 py-4 text-zinc-500 text-sm">
|
||
<svg class="w-4 h-4 animate-spin flex-shrink-0" 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>
|
||
Indexing chapter list…
|
||
</div>
|
||
<div class="flex flex-col gap-0.5 opacity-40 pointer-events-none">
|
||
{#each Array(8) as _}
|
||
<div class="h-9 rounded bg-zinc-800 animate-pulse"></div>
|
||
{/each}
|
||
</div>
|
||
{:else if chapterList.length === 0}
|
||
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
|
||
{:else}
|
||
<div class="flex flex-col gap-0.5">
|
||
{#each visibleChapters as chapter}
|
||
{@const isCurrent = data.lastChapter === chapter.number}
|
||
{@const chapterUrl = data.inLib
|
||
? `/books/${data.book.slug}/chapters/${chapter.number}`
|
||
: `/books/${data.book.slug}/chapters/${chapter.number}?preview=1&chapter_url=${encodeURIComponent((chapter as { url?: string }).url ?? '')}&title=${encodeURIComponent(chapter.title ?? '')}`}
|
||
<div class="flex items-center gap-2 px-3 py-2.5 rounded hover:bg-zinc-800/70 transition-colors group {isCurrent ? 'bg-zinc-800' : ''}">
|
||
<a href={chapterUrl} class="flex items-center gap-2 flex-1 min-w-0">
|
||
<!-- Chapter number -->
|
||
<span class="text-sm font-mono w-10 text-right flex-shrink-0 {isCurrent ? 'text-amber-400' : 'text-zinc-600'}">
|
||
{chapter.number}
|
||
</span>
|
||
<!-- Title -->
|
||
<span class="text-base {isCurrent ? 'text-amber-300' : 'text-zinc-300 group-hover:text-zinc-100'} truncate min-w-0 flex-1 transition-colors">
|
||
{chapter.title || `Chapter ${chapter.number}`}
|
||
</span>
|
||
<!-- Date label — desktop only -->
|
||
{#if (chapter as { date_label?: string }).date_label}
|
||
<span class="text-sm text-zinc-600 flex-shrink-0 max-sm:hidden">· {(chapter as { date_label?: string }).date_label}</span>
|
||
{/if}
|
||
<!-- "reading" badge -->
|
||
{#if isCurrent}
|
||
<span class="text-sm text-amber-500 flex-shrink-0 font-medium">reading</span>
|
||
{/if}
|
||
</a>
|
||
<!-- Admin: scrape from this chapter up (hover-only) -->
|
||
{#if data.isAdmin && data.book.source_url && data.inLib}
|
||
<button
|
||
onclick={() => scrapeFromChapter(chapter.number)}
|
||
disabled={rangeScraping}
|
||
class="opacity-0 group-hover:opacity-100 shrink-0 text-xs px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-500 hover:bg-amber-500/30 transition-all border border-amber-500/20 disabled:opacity-30"
|
||
title="Scrape from chapter {chapter.number} up"
|
||
>
|
||
↑ here
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- ── Admin panel (collapsed by default) ── -->
|
||
{#if data.isAdmin && data.book.source_url}
|
||
<div class="mt-6 border border-zinc-800 rounded-lg overflow-hidden">
|
||
<button
|
||
onclick={() => (adminOpen = !adminOpen)}
|
||
class="w-full flex items-center gap-2 px-4 py-2.5 text-xs font-medium text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/50 transition-colors text-left"
|
||
>
|
||
<svg class="w-3.5 h-3.5 flex-shrink-0" 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
|
||
<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-4 py-3 border-t border-zinc-800 flex flex-col gap-4">
|
||
<!-- Rescrape -->
|
||
<div class="flex items-center gap-3 flex-wrap">
|
||
<button
|
||
onclick={rescrape}
|
||
disabled={scraping}
|
||
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||
{scraping ? 'bg-zinc-700 text-zinc-500 cursor-not-allowed' : 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600'}"
|
||
>
|
||
{#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 book
|
||
{/if}
|
||
</button>
|
||
{#if scrapeResult}
|
||
<span class="text-xs {scrapeResult === 'queued' ? 'text-green-400' : scrapeResult === 'busy' ? 'text-amber-400' : 'text-red-400'}">
|
||
{scrapeResult === 'queued' ? 'Queued.' : scrapeResult === 'busy' ? 'Scraper busy.' : 'Error.'}
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Range scrape -->
|
||
<div class="flex flex-wrap items-end gap-3">
|
||
<div class="flex flex-col gap-1">
|
||
<label for="range-from" class="text-xs text-zinc-500">From chapter</label>
|
||
<input
|
||
id="range-from"
|
||
type="number"
|
||
min="1"
|
||
bind:value={rangeFrom}
|
||
placeholder="1"
|
||
class="w-24 px-2 py-1 rounded bg-zinc-700 border border-zinc-600 text-zinc-200 text-xs focus:outline-none focus:border-amber-400"
|
||
/>
|
||
</div>
|
||
<div class="flex flex-col gap-1">
|
||
<label for="range-to" class="text-xs text-zinc-500">To chapter (optional)</label>
|
||
<input
|
||
id="range-to"
|
||
type="number"
|
||
min="1"
|
||
bind:value={rangeTo}
|
||
placeholder="end"
|
||
class="w-24 px-2 py-1 rounded bg-zinc-700 border border-zinc-600 text-zinc-200 text-xs focus:outline-none focus:border-amber-400"
|
||
/>
|
||
</div>
|
||
<button
|
||
onclick={scrapeRange}
|
||
disabled={rangeScraping || !rangeFrom}
|
||
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
|
||
{rangeScraping || !rangeFrom
|
||
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
|
||
: 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/40 border border-amber-500/30'}"
|
||
>
|
||
{rangeScraping ? 'Queuing…' : 'Scrape range'}
|
||
</button>
|
||
{#if rangeResult}
|
||
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-amber-400' : 'text-red-400'}">
|
||
{rangeResult === 'queued' ? 'Range scrape queued.' : rangeResult === 'busy' ? 'Scraper busy.' : 'Error queuing.'}
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- ══════════════════════════════════════════════════ Comments ══ -->
|
||
<CommentsSection slug={data.book.slug} isLoggedIn={data.isLoggedIn} currentUserId={data.currentUserId} />
|