feat: profile page, admin pages, infinite scroll on browse
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 / UI / Build (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / Scraper / Build (pull_request) Successful in 16s
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 / UI / Build (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / Scraper / Build (pull_request) Successful in 16s
- Add /profile page with reading settings (voice, speed, auto-next) and password change form - Add /admin/scrape page showing scraping task history with live status polling and trigger controls - Add /admin/audio page showing audio cache entries with client-side search filter - Add changePassword(), listAudioCache(), listScrapingTasks() to pocketbase.ts - Add /api/admin/scrape and /api/browse-page server-side proxy routes - Replace browse page pagination with IntersectionObserver infinite scroll - Update nav: username becomes a /profile link; admin users see Scrape and Audio cache links
This commit is contained in:
@@ -18,6 +18,69 @@
|
||||
loadingSlug = slug;
|
||||
}
|
||||
|
||||
// ── Infinite scroll state ────────────────────────────────────────────────
|
||||
// novels is the accumulated list across all fetched pages.
|
||||
// Seeded from SSR page 1; new pages are appended client-side.
|
||||
let novels = $state<NovelListing[]>(data.novels);
|
||||
let currentPage = $state(data.page);
|
||||
let hasNext = $state(data.hasNext);
|
||||
let loadingMore = $state(false);
|
||||
|
||||
// A key derived from the active filters — when it changes, reset the list
|
||||
// to the fresh SSR data (SvelteKit already re-ran the server load).
|
||||
let filterKey = $derived(`${data.sort}|${data.genre}|${data.status}|${data.searchQuery}`);
|
||||
let lastFilterKey = '';
|
||||
$effect(() => {
|
||||
if (filterKey !== lastFilterKey) {
|
||||
lastFilterKey = filterKey;
|
||||
novels = data.novels;
|
||||
currentPage = data.page;
|
||||
hasNext = data.hasNext;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadNextPage() {
|
||||
if (loadingMore || !hasNext) return;
|
||||
// Infinite scroll only applies in browse mode (not rank, not search)
|
||||
if (data.sort === 'rank' || data.searchQuery) return;
|
||||
|
||||
loadingMore = true;
|
||||
const nextPage = currentPage + 1;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(nextPage),
|
||||
genre: data.genre,
|
||||
sort: data.sort,
|
||||
status: data.status
|
||||
});
|
||||
const res = await fetch(`/api/browse-page?${params.toString()}`);
|
||||
if (!res.ok) return;
|
||||
const body: { novels: NovelListing[]; page: number; hasNext: boolean } = await res.json();
|
||||
novels = [...novels, ...(body.novels ?? [])];
|
||||
currentPage = body.page ?? nextPage;
|
||||
hasNext = body.hasNext ?? false;
|
||||
} catch {
|
||||
// silently ignore — user can scroll again to retry
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── IntersectionObserver sentinel ────────────────────────────────────────
|
||||
let sentinel = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!sentinel) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) loadNextPage();
|
||||
},
|
||||
{ rootMargin: '300px' }
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
// Filter options
|
||||
const genres = [
|
||||
{ value: 'all', label: 'All Genres' },
|
||||
@@ -55,16 +118,6 @@
|
||||
const isRankView = $derived(data.sort === 'rank');
|
||||
const isSearchView = $derived(!!data.searchQuery);
|
||||
|
||||
function buildURL(overrides: Record<string, string | number>) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(data.page),
|
||||
genre: data.genre,
|
||||
sort: data.sort,
|
||||
status: data.status,
|
||||
...Object.fromEntries(Object.entries(overrides).map(([k, v]) => [k, String(v)]))
|
||||
});
|
||||
return `/browse?${params.toString()}`;
|
||||
}
|
||||
|
||||
// View toggle: 'grid' | 'list'. Default to 'list' when sort=rank (more detail).
|
||||
let view = $state<'grid' | 'list'>(data.sort === 'rank' ? 'list' : 'grid');
|
||||
@@ -111,13 +164,13 @@
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Discover</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">
|
||||
{#if isSearchView}
|
||||
{data.novels.length} result{data.novels.length !== 1 ? 's' : ''} for "<span class="text-zinc-200">{data.searchQuery}</span>"
|
||||
{novels.length} result{novels.length !== 1 ? 's' : ''} for "<span class="text-zinc-200">{data.searchQuery}</span>"
|
||||
{#if data.searchLocalCount > 0 || data.searchRemoteCount > 0}
|
||||
<span class="text-zinc-500 text-xs ml-1">({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire)</span>
|
||||
{/if}
|
||||
{:else if isRankView}
|
||||
{#if data.novels.length > 0}
|
||||
{data.novels.length} novels ranked from last catalogue scrape
|
||||
{#if novels.length > 0}
|
||||
{novels.length} novels ranked from last catalogue scrape
|
||||
{:else}
|
||||
No ranking data — run a full catalogue scrape to populate
|
||||
{/if}
|
||||
@@ -273,7 +326,7 @@
|
||||
</form>
|
||||
|
||||
<!-- Content -->
|
||||
{#if data.novels.length === 0}
|
||||
{#if novels.length === 0}
|
||||
<div class="text-center py-20 text-zinc-500">
|
||||
<p class="text-lg">{isSearchView ? 'No results found.' : isRankView ? 'No ranking data.' : 'No novels found.'}</p>
|
||||
<p class="text-sm mt-2">
|
||||
@@ -294,7 +347,7 @@
|
||||
{:else if view === 'grid'}
|
||||
<!-- ── Grid view ─────────────────────────────────────────────────────── -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
{#each data.novels as novel}
|
||||
{#each novels as novel}
|
||||
{@const isLoading = loadingSlug === novel.slug}
|
||||
<a
|
||||
href="/books/{novel.slug}"
|
||||
@@ -379,7 +432,7 @@
|
||||
{:else}
|
||||
<!-- ── List view ─────────────────────────────────────────────────────── -->
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each data.novels as novel}
|
||||
{#each novels as novel}
|
||||
{@const isLoading = loadingSlug === novel.slug}
|
||||
<div
|
||||
class="flex items-center gap-4 bg-zinc-800 border rounded-lg px-4 py-3 transition-colors
|
||||
@@ -487,25 +540,22 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pagination (browse mode only) -->
|
||||
{#if !isRankView && !isSearchView && data.novels.length > 0}
|
||||
<div class="flex items-center justify-center gap-3 mt-8">
|
||||
{#if data.page > 1}
|
||||
<a
|
||||
href={buildURL({ page: data.page - 1 })}
|
||||
class="px-4 py-2 rounded bg-zinc-800 text-zinc-200 text-sm hover:bg-zinc-700 border border-zinc-700 transition-colors"
|
||||
>
|
||||
Previous
|
||||
</a>
|
||||
{/if}
|
||||
<span class="text-zinc-400 text-sm">Page {data.page}</span>
|
||||
{#if data.hasNext}
|
||||
<a
|
||||
href={buildURL({ page: data.page + 1 })}
|
||||
class="px-4 py-2 rounded bg-zinc-800 text-zinc-200 text-sm hover:bg-zinc-700 border border-zinc-700 transition-colors"
|
||||
>
|
||||
Next
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Infinite scroll sentinel (browse mode only — not rank, not search) -->
|
||||
{#if !isRankView && !isSearchView}
|
||||
{#if hasNext}
|
||||
<!-- Invisible div watched by IntersectionObserver -->
|
||||
<div bind:this={sentinel} class="h-px mt-8"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Loading spinner while fetching next page -->
|
||||
{#if loadingMore}
|
||||
<div class="flex justify-center py-8">
|
||||
<svg class="w-6 h-6 animate-spin text-amber-400" 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>
|
||||
</div>
|
||||
{:else if !hasNext && novels.length > 0}
|
||||
<p class="text-center text-zinc-600 text-xs mt-8 pb-4">All novels loaded</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user