feat: nav progress bar, chapter list polling, live chapter fallback, and jump to current page
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / Scraper / Lint (pull_request) Successful in 10s
CI / Scraper / Test (pull_request) Successful in 16s
CI / UI / Build (pull_request) Successful in 20s
CI / Scraper / Build (pull_request) Successful in 9s

This commit is contained in:
Admin
2026-03-06 17:39:40 +05:00
parent e723459507
commit 76d616a308
5 changed files with 158 additions and 16 deletions

View File

@@ -53,3 +53,13 @@ html {
margin: 2em 0;
}
/* ── Navigation progress bar ───────────────────────────────────────── */
@keyframes progress-bar {
0% { width: 0%; opacity: 1; }
80% { width: 90%; opacity: 1; }
100% { width: 100%; opacity: 0; }
}
.animate-progress-bar {
animation: progress-bar 8s cubic-bezier(0.1, 0.05, 0.1, 1) forwards;
}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/state';
import { page, navigating } from '$app/state';
import { goto } from '$app/navigation';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
@@ -197,6 +197,12 @@
></audio>
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active}>
<!-- Navigation progress bar — shown while SSR is running for any page transition -->
{#if navigating}
<div class="fixed top-0 left-0 right-0 z-[100] h-0.5 bg-zinc-800">
<div class="h-full bg-amber-400 animate-progress-bar"></div>
</div>
{/if}
<header class="border-b border-zinc-700 bg-zinc-900 sticky top-0 z-50">
<nav class="max-w-6xl mx-auto px-4 h-14 flex items-center gap-6">
<a href="/" class="text-amber-400 font-bold text-lg tracking-tight hover:text-amber-300">

View File

@@ -0,0 +1,46 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
/**
* GET /api/chapter-text-preview/[slug]/[n]
* Proxies to the scraper's /api/chapter-text-preview endpoint.
* Used client-side when the normal chapter path returns no content
* (chapter indexed but not yet scraped to MinIO).
*/
export const GET: RequestHandler = async ({ params, url }) => {
const { slug, n } = params;
const chapter = parseInt(n, 10);
if (!slug || !chapter || chapter < 1) {
error(400, 'Invalid slug or chapter number');
}
// Forward optional query params (chapter_url, title) if present
const qs = new URLSearchParams();
const chapterUrl = url.searchParams.get('chapter_url');
const title = url.searchParams.get('title');
if (chapterUrl) qs.set('chapter_url', chapterUrl);
if (title) qs.set('title', title);
const scraperRes = await fetch(
`${SCRAPER_URL}/api/chapter-text-preview/${encodeURIComponent(slug)}/${chapter}?${qs.toString()}`
).catch((e) => {
log.error('chapter-preview', 'scraper fetch failed', { slug, chapter, err: String(e) });
return null;
});
if (!scraperRes || !scraperRes.ok) {
const status = scraperRes?.status ?? 502;
log.error('chapter-preview', 'scraper returned error', { slug, chapter, status });
error(status as Parameters<typeof error>[0], 'Chapter preview not available');
}
const data = await scraperRes.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
};

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
@@ -15,9 +17,18 @@
const genres = $derived(parseGenres(data.book.genres));
// Paginate chapter list — show 100 at a time
let page = $state(0);
const PAGE_SIZE = 100;
// 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 / PAGE_SIZE);
}
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
@@ -29,6 +40,26 @@
chapterList.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
);
// ── 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' | ''>('');
@@ -274,7 +305,21 @@
</div>
{/if}
{#if chapterList.length === 0}
{#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>
<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="grid grid-cols-1 sm:grid-cols-2 gap-1 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="grid grid-cols-1 sm:grid-cols-2 gap-1">

View File

@@ -2,20 +2,47 @@
import { onMount } from 'svelte';
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
import type { PageData } from './$types';
import { marked } from 'marked';
let { data }: { data: PageData } = $props();
// Record reading progress when the chapter is opened (skip for preview chapters)
// ── Live-fetch fallback when chapter text is missing in storage ───────────
let html = $state(data.html);
let fetchingContent = $state(!data.isPreview && !data.html);
let fetchError = $state('');
onMount(async () => {
if (data.isPreview) return;
try {
await fetch('/api/progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: data.book.slug, chapter: data.chapter.number })
});
} catch {
// Non-critical — silently ignore
// Record reading progress (skip for preview chapters)
if (!data.isPreview) {
try {
await fetch('/api/progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: data.book.slug, chapter: data.chapter.number })
});
} catch {
// Non-critical — silently ignore
}
}
// If the normal path returned no content, fall back to live preview scrape
if (!data.isPreview && !data.html) {
try {
const res = await fetch(
`/api/chapter-text-preview/${encodeURIComponent(data.book.slug)}/${data.chapter.number}`
);
if (!res.ok) throw new Error(`status ${res.status}`);
const d = (await res.json()) as { text?: string };
if (d.text) {
html = await marked(d.text, { async: true });
} else {
fetchError = 'Chapter content not available.';
}
} catch (e) {
fetchError = 'Could not fetch chapter content.';
} finally {
fetchingContent = false;
}
}
});
</script>
@@ -85,13 +112,21 @@
{/if}
<!-- Chapter content -->
{#if !data.html}
{#if fetchingContent}
<div class="flex flex-col items-center gap-3 py-16 text-zinc-500 text-sm">
<svg class="w-6 h-6 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>
Fetching chapter…
</div>
{:else if !html}
<div class="text-zinc-500 text-center py-16">
<p>Chapter content not available.</p>
<p>{fetchError || 'Chapter content not available.'}</p>
</div>
{:else}
<div class="prose-chapter mt-8">
{@html data.html}
{@html html}
</div>
{/if}