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
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
46
ui/src/routes/api/chapter-text-preview/[slug]/[n]/+server.ts
Normal file
46
ui/src/routes/api/chapter-text-preview/[slug]/[n]/+server.ts
Normal 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' }
|
||||
});
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user