v2 #1

Open
kamil wants to merge 231 commits from v2 into main
Showing only changes of commit a72c1f6b52 - Show all commits

View File

@@ -148,211 +148,180 @@
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>
<!-- Book header -->
<div class="flex gap-6 mb-8">
<!-- ═══════════════════════════════════════════════════════════════ Hero ══ -->
<div class="relative rounded-xl overflow-hidden mb-8">
<!-- Blurred cover background -->
{#if data.book.cover}
<img
src={data.book.cover}
alt={data.book.title}
class="w-32 sm:w-40 rounded-lg object-cover flex-shrink-0 border border-zinc-700"
/>
<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="flex flex-col gap-2 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h1 class="text-2xl font-bold text-zinc-100 leading-tight">{data.book.title}</h1>
{#if !data.inLib}
<span class="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>
{#if data.book.author}
<p class="text-zinc-400 text-sm">{data.book.author}</p>
<div class="relative flex gap-5 sm:gap-8 p-5 sm:p-7">
<!-- Cover image -->
{#if data.book.cover}
<img
src={data.book.cover}
alt={data.book.title}
class="w-36 sm:w-48 rounded-lg object-cover flex-shrink-0 border border-zinc-700 shadow-xl self-start"
/>
{/if}
<div class="flex flex-wrap gap-2 mt-1">
{#if data.book.status}
<span class="text-xs px-2 py-1 rounded bg-zinc-700 text-zinc-300">{data.book.status}</span>
{/if}
{#each genres as genre}
<span class="text-xs px-2 py-1 rounded bg-zinc-800 text-zinc-400">{genre}</span>
{/each}
</div>
<!-- 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-2xl 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>
{#if data.book.summary}
<p class="text-zinc-400 text-sm leading-relaxed line-clamp-4 mt-1">{data.book.summary}</p>
{/if}
<!-- Author -->
{#if data.book.author}
<p class="text-zinc-400 text-sm">{data.book.author}</p>
{/if}
<div class="flex gap-3 mt-2 flex-wrap items-center">
{#if data.lastChapter}
<a
href="/books/{data.book.slug}/chapters/{data.lastChapter}"
class="px-4 py-2 bg-amber-400 text-zinc-900 font-semibold rounded text-sm hover:bg-amber-300 transition-colors"
>
Continue ch.{data.lastChapter}
</a>
{/if}
{#if chapterList.length > 0}
<a
href="/books/{data.book.slug}/chapters/1"
class="px-4 py-2 bg-zinc-700 text-zinc-100 font-semibold rounded text-sm hover:bg-zinc-600 transition-colors"
>
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
</a>
{/if}
<!-- Save / unsave button -->
{#if data.inLib}
<button
onclick={toggleSave}
disabled={saving}
title={saved ? 'Remove from library' : 'Add to library'}
class="flex items-center gap-1.5 px-3 py-2 rounded text-sm font-medium transition-colors disabled:opacity-50
{saved
? 'bg-amber-400/20 text-amber-300 hover:bg-red-500/20 hover:text-red-300 border border-amber-400/30 hover:border-red-400/30'
: 'bg-zinc-700 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-600 border border-zinc-600'}"
>
{#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>
<!-- 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 {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}
{saved ? 'Saved' : 'Save'}
</button>
</div>
{/if}
<!-- CTA buttons -->
<div class="flex gap-2 mt-3 flex-wrap items-center">
<!-- Primary: Continue (shown only when there's a last chapter) -->
{#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}
<!-- Secondary: Start / Preview ch.1 -->
{#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}
<!-- Save: icon-only bookmark button -->
{#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>
</div>
<!-- Chapter list -->
<div class="mt-4">
<!-- ══════════════════════════════════════════════════ Chapter list ══ -->
<div>
<!-- Header row: title + pagination -->
<div class="flex items-center justify-between mb-3 flex-wrap gap-2">
<h2 class="text-lg font-semibold text-zinc-100">
<h2 class="text-base font-semibold text-zinc-200">
Chapters
<span class="text-zinc-500 font-normal text-sm ml-1">({chapterList.length})</span>
{#if chapterList.length > 0}
<span class="text-zinc-500 font-normal text-sm ml-1">({chapterList.length})</span>
{/if}
</h2>
<div class="flex items-center gap-3 flex-wrap">
{#if data.isAdmin && data.book.source_url}
{#if totalPages > 1}
<div class="flex gap-2 items-center text-sm">
<button
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"
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"
>
{#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}
&larr;
</button>
{/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>
<span class="text-zinc-500 text-xs tabular-nums">
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, 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"
>
&rarr;
</button>
</div>
{/if}
</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}
<!-- Admin: range scrape controls -->
{#if data.isAdmin && data.book.source_url}
<div class="mb-4 p-3 rounded bg-zinc-800/60 border border-zinc-700 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>
{/if}
<!-- Chapter rows -->
{#if pollingChapters}
<!-- Chapter list is being indexed in the background -->
<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>
@@ -360,7 +329,7 @@
</svg>
Indexing chapter list…
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1 opacity-40 pointer-events-none">
<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}
@@ -368,35 +337,32 @@
{:else if chapterList.length === 0}
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1">
<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-3 px-3 py-2 rounded hover:bg-zinc-800 transition-colors group {isCurrent ? 'bg-zinc-800' : ''}">
<a
href={chapterUrl}
class="flex items-center gap-3 flex-1 min-w-0"
>
<span
class="text-xs font-mono w-10 text-right flex-shrink-0 {isCurrent
? 'text-amber-400'
: 'text-zinc-500'}"
>
<div class="flex items-center gap-2 px-3 py-2 rounded hover:bg-zinc-800/70 transition-colors group {isCurrent ? 'bg-zinc-800' : ''}">
<a href={chapterUrl} class="flex items-baseline gap-2 flex-1 min-w-0">
<!-- Chapter number -->
<span class="text-xs font-mono w-9 text-right flex-shrink-0 {isCurrent ? 'text-amber-400' : 'text-zinc-600'}">
{chapter.number}
</span>
<span class="text-sm text-zinc-300 group-hover:text-zinc-100 truncate flex-1">
<!-- Title -->
<span class="text-sm {isCurrent ? 'text-amber-300' : 'text-zinc-300 group-hover:text-zinc-100'} truncate flex-1 transition-colors">
{chapter.title || `Chapter ${chapter.number}`}
</span>
{#if isCurrent}
<span class="text-xs text-amber-400 flex-shrink-0">reading</span>
{/if}
<!-- Date label, separated visually -->
{#if (chapter as { date_label?: string }).date_label}
<span class="text-xs text-zinc-600 flex-shrink-0 hidden sm:block">{(chapter as { date_label?: string }).date_label}</span>
<span class="text-xs text-zinc-600 flex-shrink-0 hidden sm:block">&middot; {(chapter as { date_label?: string }).date_label}</span>
{/if}
<!-- "reading" badge -->
{#if isCurrent}
<span class="text-xs text-amber-500 flex-shrink-0 font-medium">reading</span>
{/if}
</a>
<!-- Admin: scrape from this chapter up -->
<!-- Admin: scrape from this chapter up (hover-only) -->
{#if data.isAdmin && data.book.source_url && data.inLib}
<button
onclick={() => scrapeFromChapter(chapter.number)}
@@ -411,4 +377,96 @@
{/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>