Files
libnovel/ui/src/routes/books/[slug]/+page.svelte
Admin 09cdda2a07
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
feat: add avatars to comments (web + iOS) with replies, delete, sort, and crop fix
- 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
2026-03-10 20:05:31 +05:00

532 lines
21 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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"
>
&larr;
</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"
>
&rarr;
</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">&middot; {(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} />