diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte
index b212900..8e306a4 100644
--- a/ui/src/routes/+layout.svelte
+++ b/ui/src/routes/+layout.svelte
@@ -24,10 +24,7 @@
Library
- Browse
-
-
- Ranking
+ Discover
{data.user.username}
diff --git a/ui/src/routes/browse/+page.server.ts b/ui/src/routes/browse/+page.server.ts
index 96c5ac5..89a2f01 100644
--- a/ui/src/routes/browse/+page.server.ts
+++ b/ui/src/routes/browse/+page.server.ts
@@ -1,5 +1,5 @@
import { error } from '@sveltejs/kit';
-import type { PageServerLoad } from './$types';
+import type { PageServerLoad, Actions } from './$types';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
@@ -13,6 +13,11 @@ export interface NovelListing {
rating: string;
chapters: string;
url: string;
+ // enriched fields (only set when sort=rank)
+ author?: string;
+ status?: string;
+ genres?: string[];
+ source_url?: string;
}
export const load: PageServerLoad = async ({ url, locals }) => {
@@ -21,30 +26,100 @@ export const load: PageServerLoad = async ({ url, locals }) => {
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()}`;
+ let novels: NovelListing[] = [];
+ let pageNum = parseInt(page, 10) || 1;
+ let hasNext = false;
- let data: { novels: NovelListing[]; page: number; hasNext: boolean };
- try {
- const res = await fetch(apiURL);
- if (!res.ok) {
- log.error('browse', 'scraper browse returned error', { status: res.status, url: apiURL });
- throw error(502, `Browse fetch failed: ${res.status}`);
+ if (sort === 'rank') {
+ // Ranking view: fetch from /api/ranking which returns richer metadata.
+ // Pagination and filters (genre/status) don't apply here — the ranking
+ // is a single pre-computed list from the last catalogue scrape.
+ const apiURL = `${SCRAPER_URL}/api/ranking`;
+ try {
+ const res = await fetch(apiURL);
+ if (!res.ok) {
+ log.error('browse', 'scraper ranking returned error', { status: res.status });
+ throw error(502, `Ranking fetch failed: ${res.status}`);
+ }
+ const items: Array<{
+ rank: number;
+ slug: string;
+ title: string;
+ author: string;
+ cover: string;
+ status: string;
+ genres: string[];
+ source_url: string;
+ }> = await res.json();
+ novels = (items ?? []).map((item) => ({
+ slug: item.slug,
+ title: item.title,
+ cover: item.cover,
+ rank: item.rank != null ? `#${item.rank}` : '',
+ rating: '',
+ chapters: '',
+ url: item.source_url ?? '',
+ author: item.author,
+ status: item.status,
+ genres: item.genres ?? [],
+ source_url: item.source_url
+ }));
+ pageNum = 1;
+ hasNext = false;
+ } catch (e) {
+ if (e instanceof Error && 'status' in e) throw e;
+ log.error('browse', 'scraper ranking network error', { err: String(e) });
+ throw error(502, 'Could not load ranking');
+ }
+ } else {
+ // Browse view: paginated catalogue from /api/browse.
+ 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', 'scraper browse returned error', { status: res.status, url: apiURL });
+ throw error(502, `Browse fetch failed: ${res.status}`);
+ }
+ const data: { novels: NovelListing[]; page: number; hasNext: boolean } = await res.json();
+ novels = data.novels ?? [];
+ pageNum = data.page ?? 1;
+ hasNext = data.hasNext ?? false;
+ } catch (e) {
+ if (e instanceof Error && 'status' in e) throw e;
+ log.error('browse', 'scraper browse network error', { url: apiURL, err: String(e) });
+ throw error(502, 'Could not load browse page');
}
- data = await res.json();
- } catch (e) {
- if (e instanceof Error && 'status' in e) throw e;
- log.error('browse', 'scraper browse network error', { url: apiURL, err: String(e) });
- throw error(502, 'Could not load browse page');
}
return {
- novels: data.novels ?? [],
- page: data.page ?? 1,
- hasNext: data.hasNext ?? false,
+ novels,
+ page: pageNum,
+ hasNext,
genre,
sort,
status,
isAdmin: locals.user?.role === 'admin'
};
};
+
+// Admin action: trigger a full catalogue scrape (refreshes ranking + library).
+export const actions: Actions = {
+ refresh: async ({ locals, fetch }) => {
+ if (!locals.user || locals.user.role !== 'admin') {
+ throw error(403, 'Forbidden');
+ }
+ try {
+ const res = await fetch('/api/scrape', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({})
+ });
+ if (res.status === 409) return { status: 'busy' };
+ if (!res.ok) return { status: 'error' };
+ return { status: 'queued' };
+ } catch {
+ return { status: 'error' };
+ }
+ }
+};
diff --git a/ui/src/routes/browse/+page.svelte b/ui/src/routes/browse/+page.svelte
index 4fbd4a5..104f3ef 100644
--- a/ui/src/routes/browse/+page.svelte
+++ b/ui/src/routes/browse/+page.svelte
@@ -1,8 +1,9 @@
- Browse — libnovel
+ Discover — libnovel
-
-
Browse
-
Discover novels from novelfire.net
+
+
+
+
Discover
+
+ {#if isRankView}
+ {#if data.novels.length > 0}
+ {data.novels.length} novels ranked from last catalogue scrape
+ {:else}
+ No ranking data — run a full catalogue scrape to populate
+ {/if}
+ {:else}
+ Browse novels from novelfire.net
+ {/if}
+
+
+
+
+
+
+
(view = 'grid')}
+ title="Grid view"
+ class="px-2.5 py-1.5 transition-colors {view === 'grid'
+ ? 'bg-zinc-600 text-zinc-100'
+ : 'text-zinc-400 hover:text-zinc-200'}"
+ >
+
+
+
+
+
+
(view = 'list')}
+ title="List view"
+ class="px-2.5 py-1.5 transition-colors {view === 'list'
+ ? 'bg-zinc-600 text-zinc-100'
+ : 'text-zinc-400 hover:text-zinc-200'}"
+ >
+
+
+
+
+
+
+
+
+ {#if data.isAdmin}
+
+ {/if}
+
+
+{#if form}
+ {#if form.status === 'queued'}
+
+ Full catalogue scrape queued. Library and ranking will update as books are processed.
+
+ {:else if form.status === 'busy'}
+
+ A scrape job is already running. Check back once it finishes.
+
+ {:else if form.status === 'error'}
+
+ Failed to queue scrape. Check that the scraper service is reachable.
+
+ {/if}
+{/if}
+
-
+
{#if data.novels.length === 0}
-
No novels found.
-
Try different filters or check back later.
+
{isRankView ? 'No ranking data.' : 'No novels found.'}
+
+ {#if isRankView}
+ {#if data.isAdmin}
+ Click Refresh catalogue above to trigger a full catalogue scrape.
+ {:else}
+ Ask an admin to run a catalogue scrape.
+ {/if}
+ {:else}
+ Try different filters or check back later.
+ {/if}
+
-{:else}
+
+{:else if view === 'grid'}
+
{#each data.novels as novel}
-
+
{#if novel.cover}
@@ -151,30 +263,18 @@
{:else}
{/if}
-
-
{#if novel.rank}
-
+
{novel.rank}
{/if}
-
-
{#if novel.rating}
-
+
{novel.rating}
{/if}
@@ -182,16 +282,15 @@
-
- {novel.title}
-
-
- {#if novel.chapters}
+
{novel.title}
+ {#if novel.author}
+
{novel.author}
+ {:else if novel.chapters}
{novel.chapters}
{/if}
-
- {#if data.isAdmin}
+
+ {#if data.isAdmin && novel.url}
{#if scrapeResult[novel.slug] === 'queued'}
Queued
@@ -217,7 +316,105 @@
{/each}
-
+{:else}
+
+
+ {#each data.novels as novel}
+
+
+ {#if novel.rank}
+
{novel.rank}
+ {/if}
+
+
+
+ {#if novel.cover}
+
+ {:else}
+
+ {/if}
+
+
+
+
+ {#if novel.slug}
+
+ {novel.title}
+
+ {:else}
+
{novel.title}
+ {/if}
+
+ {#if novel.author}
+ {novel.author}
+ {/if}
+ {#if novel.status}
+ {novel.status}
+ {:else if novel.chapters}
+ {novel.chapters}
+ {/if}
+ {#if novel.rating}
+ ★ {novel.rating}
+ {/if}
+ {#if novel.genres?.length}
+ {#each novel.genres.slice(0, 3) as genre}
+ {genre}
+ {/each}
+ {/if}
+
+
+
+
+ {#if data.isAdmin && novel.url}
+
+ {#if scrapeResult[novel.slug] === 'queued'}
+ Queued
+ {:else if scrapeResult[novel.slug] === 'busy'}
+ Busy
+ {:else if scrapeResult[novel.slug] === 'error'}
+ Error
+ {:else}
+ scrapeNovel(novel)}
+ disabled={scraping[novel.slug]}
+ class="text-xs px-2.5 py-1 rounded bg-amber-500/20 text-amber-300 hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30 whitespace-nowrap"
+ >
+ {scraping[novel.slug] ? 'Scraping…' : 'Scrape'}
+
+ {/if}
+
+ {/if}
+
+
+ {#if novel.source_url || novel.url}
+
+
+
+
+
+ {/if}
+
+ {/each}
+
+{/if}
+
+
+{#if !isRankView && data.novels.length > 0}