From c2d6ce1c5bedd923b30f5889c1e8df5ae5704b3c Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 4 Mar 2026 10:11:22 +0500 Subject: [PATCH] feat(ui): merge Browse and Ranking into a unified Discover page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /ranking route is removed. /browse becomes the Discover page with: - Sort=Ranking option: fetches from /api/ranking (richer metadata — author, genres, source_url) and renders the full pre-computed ranked list - Sort=Popular/New/Updated: fetches from /api/browse as before, paginated - Grid / List view toggle: grid is the default for browse; list auto-selects for ranking and gives the information-dense ranked layout (was /ranking) - Admin Refresh catalogue button (moved from /ranking) and per-novel Scrape button work in both views - Genre and status filters are disabled (visually dimmed) when sort=rank since the ranking endpoint does not support per-page filtering - Nav: 'Browse' renamed to 'Discover', 'Ranking' link removed --- ui/src/routes/+layout.svelte | 5 +- ui/src/routes/browse/+page.server.ts | 109 ++++++++-- ui/src/routes/browse/+page.svelte | 297 +++++++++++++++++++++----- ui/src/routes/ranking/+page.server.ts | 68 ------ ui/src/routes/ranking/+page.svelte | 140 ------------ 5 files changed, 339 insertions(+), 280 deletions(-) delete mode 100644 ui/src/routes/ranking/+page.server.ts delete mode 100644 ui/src/routes/ranking/+page.svelte 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
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} +

+
+ +
+ +
+ + +
+ + + {#if data.isAdmin} +
{ + refreshing = true; + return async ({ update }) => { + await update(); + refreshing = false; + }; + }} + > + +
+ {/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} +
@@ -92,7 +188,8 @@ {#each statuses as st} @@ -125,20 +223,34 @@ > Filter + + {#if isRankView} + Genre & status filters apply to Browse only + {/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} + {novel.title} + {: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} + + {/if} +
+ {/if} + + + {#if novel.source_url || novel.url} + + + + + + {/if} +
+ {/each} +
+{/if} + + +{#if !isRankView && data.novels.length > 0}
{#if data.page > 1} {/if} - Page {data.page} - {#if data.hasNext} { - const apiURL = `${SCRAPER_URL}/api/ranking`; - - let items: RankingItem[] = []; - try { - const res = await fetch(apiURL); - if (!res.ok) { - log.error('ranking', 'scraper ranking returned error', { status: res.status }); - throw error(502, `Ranking fetch failed: ${res.status}`); - } - items = await res.json(); - } catch (e) { - if (e instanceof Error && 'status' in e) throw e; - log.error('ranking', 'scraper ranking network error', { err: String(e) }); - throw error(502, 'Could not load ranking'); - } - - return { - items: items ?? [], - isAdmin: locals.user?.role === 'admin' - }; -}; - -// Admin action: trigger a full catalogue scrape to refresh ranking data. -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/ranking/+page.svelte b/ui/src/routes/ranking/+page.svelte deleted file mode 100644 index 4fb0fd8..0000000 --- a/ui/src/routes/ranking/+page.svelte +++ /dev/null @@ -1,140 +0,0 @@ - - - - Ranking — libnovel - - -
-
-

Ranking

-

- {#if data.items.length > 0} - {data.items.length} novels cached from last catalogue scrape - {:else} - No ranking data yet — run a full catalogue scrape to populate - {/if} -

-
- - {#if data.isAdmin} -
{ - refreshing = true; - return async ({ update }) => { - await update(); - refreshing = false; - }; - }} - > - -
- {/if} -
- -{#if form} - {#if form.status === 'queued'} -
- Full catalogue scrape queued. 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.items.length === 0} -
-

No ranking data.

-

- {#if data.isAdmin} - Click Refresh ranking above to trigger a full catalogue scrape. - {:else} - Ask an admin to run a catalogue scrape. - {/if} -

-
-{:else} -
- {#each data.items as item} -
- - - #{item.rank} - - - -
- {#if item.cover} - {item.title} - {:else} -
- - - -
- {/if} -
- - -
- - {item.title} - -
- {#if item.author} - {item.author} - {/if} - {#if item.status} - {item.status} - {/if} - {#if item.genres?.length} - {#each item.genres.slice(0, 3) as genre} - {genre} - {/each} - {/if} -
-
- - - {#if item.source_url} - - - - - - {/if} -
- {/each} -
-{/if}