From 8f0a2f7e925fffa7e7f42312538709fa41cc7924 Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 6 Mar 2026 18:58:24 +0500 Subject: [PATCH] feat: profile page, admin pages, infinite scroll on browse - 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 --- ui/src/lib/server/pocketbase.ts | 71 +++++++ ui/src/routes/+layout.svelte | 21 +- ui/src/routes/admin/audio/+page.server.ts | 17 ++ ui/src/routes/admin/audio/+page.svelte | 92 +++++++++ ui/src/routes/admin/scrape/+page.server.ts | 29 +++ ui/src/routes/admin/scrape/+page.svelte | 195 ++++++++++++++++++ ui/src/routes/api/admin/scrape/+server.ts | 23 +++ ui/src/routes/api/browse-page/+server.ts | 37 ++++ ui/src/routes/browse/+page.svelte | 124 ++++++++---- ui/src/routes/profile/+page.server.ts | 50 +++++ ui/src/routes/profile/+page.svelte | 224 +++++++++++++++++++++ 11 files changed, 845 insertions(+), 38 deletions(-) create mode 100644 ui/src/routes/admin/audio/+page.server.ts create mode 100644 ui/src/routes/admin/audio/+page.svelte create mode 100644 ui/src/routes/admin/scrape/+page.server.ts create mode 100644 ui/src/routes/admin/scrape/+page.svelte create mode 100644 ui/src/routes/api/admin/scrape/+server.ts create mode 100644 ui/src/routes/api/browse-page/+server.ts create mode 100644 ui/src/routes/profile/+page.server.ts create mode 100644 ui/src/routes/profile/+page.svelte diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index e9b5ff1..134dd79 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -479,6 +479,44 @@ export async function createUser(username: string, password: string, role = 'use return res.json() as Promise; } +/** + * Change a user's password. Verifies the current password first. + * Returns true on success, false if currentPassword is wrong. + * Throws on unexpected errors. + */ +export async function changePassword( + userId: string, + currentPassword: string, + newPassword: string +): Promise { + // Fetch the user record directly by id to verify current password + const token = await getToken(); + const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'changePassword: fetch user failed', { userId, status: res.status, body }); + throw new Error(`Failed to fetch user: ${res.status}`); + } + const user = (await res.json()) as User; + if (!verifyPassword(currentPassword, user.password_hash)) { + log.warn('pocketbase', 'changePassword: wrong current password', { userId }); + return false; + } + const newHash = hashPassword(newPassword); + const patch = await pbPatch(`/api/collections/app_users/records/${userId}`, { + password_hash: newHash + }); + if (!patch.ok) { + const body = await patch.text().catch(() => ''); + log.error('pocketbase', 'changePassword: PATCH failed', { userId, status: patch.status, body }); + throw new Error(`Failed to update password: ${patch.status}`); + } + log.info('pocketbase', 'changePassword: success', { userId }); + return true; +} + /** * Verify username + password. Returns the user on success, null on failure. */ @@ -586,6 +624,39 @@ export async function setAudioTime( } } +// ─── Audio cache ────────────────────────────────────────────────────────────── + +export interface AudioCacheEntry { + id: string; + cache_key: string; + filename: string; + updated: string; +} + +export async function listAudioCache(): Promise { + return listAll('audio_cache', '', '-updated'); +} + +// ─── Scraping tasks ─────────────────────────────────────────────────────────── + +export interface ScrapingTask { + id: string; + kind: string; + target_url: string; + status: string; + books_found: number; + chapters_scraped: number; + chapters_skipped: number; + errors: number; + started: string; + finished: string; + error_message: string; +} + +export async function listScrapingTasks(): Promise { + return listAll('scraping_tasks', '', '-started'); +} + export async function getAudioTime( sessionId: string, slug: string, diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index aa1a3a7..676474a 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -222,7 +222,26 @@ Discover
- + {#if data.user?.role === 'admin'} + + Scrape + + + Audio cache + + {/if} + + {data.user.username} +
+
+ + + +
+

Scrape a single book

+
+ + +
+ {#if scrapeError} +

{scrapeError}

+ {/if} +
+ + + {#if tasks.length === 0} +

No scrape tasks yet.

+ {:else} +
+ + + + + + + + + + + + + + + {#each tasks as task} + + + + + + + + + + + {#if task.error_message} + + + + {/if} + {/each} + +
KindStatusBooksChaptersSkippedErrorsStartedDuration
+ {task.kind} + {#if task.target_url} +
+ + {task.target_url.replace('https://novelfire.net/book/', '')} + + {/if} +
+ {task.status} + {task.books_found ?? 0}{task.chapters_scraped ?? 0}{task.chapters_skipped ?? 0}{task.errors ?? 0}{fmtDate(task.started)}{duration(task.started, task.finished)}
{task.error_message}
+
+ {/if} + diff --git a/ui/src/routes/api/admin/scrape/+server.ts b/ui/src/routes/api/admin/scrape/+server.ts new file mode 100644 index 0000000..a21b5f3 --- /dev/null +++ b/ui/src/routes/api/admin/scrape/+server.ts @@ -0,0 +1,23 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; + +const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080'; + +/** + * GET /api/admin/scrape/status + * Admin-only proxy to the Go scraper's /api/scrape/status endpoint. + */ +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + try { + const res = await fetch(`${SCRAPER_URL}/api/scrape/status`); + if (!res.ok) return json({ running: false }); + const data = await res.json(); + return json({ running: data.running ?? false }); + } catch { + return json({ running: false }); + } +}; diff --git a/ui/src/routes/api/browse-page/+server.ts b/ui/src/routes/api/browse-page/+server.ts new file mode 100644 index 0000000..b22ea49 --- /dev/null +++ b/ui/src/routes/api/browse-page/+server.ts @@ -0,0 +1,37 @@ +import { json, 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/browse-page?page=2&genre=all&sort=popular&status=all + * + * Thin proxy to the Go scraper's /api/browse endpoint. + * Used by the infinite-scroll browse page to append subsequent pages + * without a full SSR navigation. + */ +export const GET: RequestHandler = async ({ url }) => { + const page = url.searchParams.get('page') ?? '1'; + const genre = url.searchParams.get('genre') ?? 'all'; + const sort = url.searchParams.get('sort') ?? 'popular'; + const status = url.searchParams.get('status') ?? 'all'; + + const params = new URLSearchParams({ page, genre, sort, status }); + const apiURL = `${SCRAPER_URL}/api/browse?${params.toString()}`; + + try { + const res = await fetch(apiURL); + if (!res.ok) { + log.error('browse-page', 'scraper returned error', { status: res.status }); + throw error(502, `Browse fetch failed: ${res.status}`); + } + const data = await res.json(); + return json(data); + } catch (e) { + if (e instanceof Error && 'status' in e) throw e; + log.error('browse-page', 'network error', { err: String(e) }); + throw error(502, 'Could not reach browse service'); + } +}; diff --git a/ui/src/routes/browse/+page.svelte b/ui/src/routes/browse/+page.svelte index 33dedaa..85ba2f9 100644 --- a/ui/src/routes/browse/+page.svelte +++ b/ui/src/routes/browse/+page.svelte @@ -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(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(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) { - 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 @@

Discover

{#if isSearchView} - {data.novels.length} result{data.novels.length !== 1 ? 's' : ''} for "{data.searchQuery}" + {novels.length} result{novels.length !== 1 ? 's' : ''} for "{data.searchQuery}" {#if data.searchLocalCount > 0 || data.searchRemoteCount > 0} ({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire) {/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 @@ -{#if data.novels.length === 0} +{#if novels.length === 0}

{isSearchView ? 'No results found.' : isRankView ? 'No ranking data.' : 'No novels found.'}

@@ -294,7 +347,7 @@ {:else if view === 'grid'}

- {#each data.novels as novel} + {#each novels as novel} {@const isLoading = loadingSlug === novel.slug}
- {#each data.novels as novel} + {#each novels as novel} {@const isLoading = loadingSlug === novel.slug} + +{#if !isRankView && !isSearchView} + {#if hasNext} + +
+ {/if} + + + {#if loadingMore} +
+ + + + +
+ {:else if !hasNext && novels.length > 0} +

All novels loaded

+ {/if} {/if} diff --git a/ui/src/routes/profile/+page.server.ts b/ui/src/routes/profile/+page.server.ts new file mode 100644 index 0000000..1cf9e41 --- /dev/null +++ b/ui/src/routes/profile/+page.server.ts @@ -0,0 +1,50 @@ +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; +import { changePassword } from '$lib/server/pocketbase'; +import { log } from '$lib/server/logger'; + +export const load: PageServerLoad = async ({ locals }) => { + if (!locals.user) { + redirect(302, '/login'); + } + return { + user: locals.user + }; +}; + +export const actions: Actions = { + changePassword: async ({ request, locals }) => { + if (!locals.user) { + return fail(401, { error: 'Not logged in.' }); + } + + const data = await request.formData(); + const current = (data.get('current') as string | null) ?? ''; + const next = (data.get('next') as string | null) ?? ''; + const confirm = (data.get('confirm') as string | null) ?? ''; + + if (!current || !next || !confirm) { + return fail(400, { error: 'All fields are required.' }); + } + if (next.length < 8) { + return fail(400, { error: 'New password must be at least 8 characters.' }); + } + if (next !== confirm) { + return fail(400, { error: 'New passwords do not match.' }); + } + + let ok: boolean; + try { + ok = await changePassword(locals.user.id, current, next); + } catch (e) { + log.error('profile', 'changePassword failed', { err: String(e) }); + return fail(500, { error: 'An error occurred. Please try again.' }); + } + + if (!ok) { + return fail(401, { error: 'Current password is incorrect.' }); + } + + return { success: true }; + } +}; diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte new file mode 100644 index 0000000..d97affc --- /dev/null +++ b/ui/src/routes/profile/+page.svelte @@ -0,0 +1,224 @@ + + + + Profile — libnovel + + +
+
+

Profile

+

Signed in as {data.user.username}

+
+ + +
+

Reading settings

+ + +
+ + {#if !voicesLoaded} +
+ {:else if voices.length === 0} + + {:else} + + {/if} +
+ + +
+ + +
+ 0.5x + 3.0x +
+
+ + + + +
+ + {#if settingsSaved} + Saved! + {/if} +
+
+ + +
+

Change password

+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + {#if pwSuccess} +
+ Password changed successfully. +
+ {/if} + +
{ + pwSubmitting = true; + return async ({ update }) => { + pwSubmitting = false; + await update(); + }; + }} + class="space-y-4" + > +
+ + +
+
+ + +
+
+ + +
+ +
+
+