feat(ui): add /browse page and /api/scrape proxy route

- /browse calls GET /api/browse on the scraper and renders a novel grid mirroring the
  novelfire layout: cover, rank/rating badges, chapter count, genre/sort/status filters,
  and pagation controls
- Scrape buttons are shown only to admin users; clicking enqueues the book via /api/scrape
- /api/scrape is an admin-only SvelteKit server route that proxies POST requests to the
  Go scraper's /scrape/book or /scrape endpoints; returns 403 for non-admins
This commit is contained in:
Admin
2026-03-03 14:03:27 +05:00
parent f265d9d020
commit 9fa0776258
3 changed files with 338 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
/**
* POST /api/scrape
*
* Proxies scrape requests to the Go scraper backend.
* Admin-only — returns 403 if the authenticated user is not an admin.
*
* Request body (JSON):
* { "url": "https://novelfire.net/book/..." } — scrape a single book
* {} — scrape the full catalogue
*
* Responses mirror the Go scraper:
* 202 Accepted — job enqueued
* 409 Conflict — a scrape job is already running
* 400 Bad Request
* 403 Forbidden — not an admin
*/
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';
export const POST: RequestHandler = async ({ request, locals }) => {
// Admin guard
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
let body: { url?: string } = {};
try {
body = await request.json();
} catch {
// empty body is fine — means "scrape all"
}
// Decide which scraper endpoint to call
const isBookScrape = typeof body.url === 'string' && body.url.length > 0;
const endpoint = isBookScrape ? '/scrape/book' : '/scrape';
const upstream = `${SCRAPER_URL}${endpoint}`;
const res = await fetch(upstream, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: isBookScrape ? JSON.stringify({ url: body.url }) : undefined
});
const data = await res.json().catch(() => ({}));
// Pass through the status code from the Go scraper (202, 409, 400, …)
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,47 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { env } from '$env/dynamic/private';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
export interface NovelListing {
slug: string;
title: string;
cover: string;
rank: string;
rating: string;
chapters: string;
url: string;
}
export const load: PageServerLoad = async ({ url, locals }) => {
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()}`;
let data: { novels: NovelListing[]; page: number; hasNext: boolean };
try {
const res = await fetch(apiURL);
if (!res.ok) {
throw error(502, `Browse fetch failed: ${res.status}`);
}
data = await res.json();
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
throw error(502, 'Could not load browse page');
}
return {
novels: data.novels ?? [],
page: data.page ?? 1,
hasNext: data.hasNext ?? false,
genre,
sort,
status,
isAdmin: locals.user?.role === 'admin'
};
};

View File

@@ -0,0 +1,239 @@
<script lang="ts">
import type { PageData } from './$types';
import type { NovelListing } from './+page.server';
let { data }: { data: PageData } = $props();
// Filter options
const genres = [
{ value: 'all', label: 'All Genres' },
{ value: 'action', label: 'Action' },
{ value: 'adventure', label: 'Adventure' },
{ value: 'comedy', label: 'Comedy' },
{ value: 'drama', label: 'Drama' },
{ value: 'fantasy', label: 'Fantasy' },
{ value: 'harem', label: 'Harem' },
{ value: 'historical', label: 'Historical' },
{ value: 'horror', label: 'Horror' },
{ value: 'isekai', label: 'Isekai' },
{ value: 'martial-arts', label: 'Martial Arts' },
{ value: 'mystery', label: 'Mystery' },
{ value: 'psychological', label: 'Psychological' },
{ value: 'romance', label: 'Romance' },
{ value: 'sci-fi', label: 'Sci-Fi' },
{ value: 'system', label: 'System' },
{ value: 'xianxia', label: 'Xianxia' }
];
const sorts = [
{ value: 'popular', label: 'Popular' },
{ value: 'new', label: 'New' },
{ value: 'update', label: 'Updated' },
{ value: 'rank', label: 'Rank' }
];
const statuses = [
{ value: 'all', label: 'All' },
{ value: 'ongoing', label: 'Ongoing' },
{ value: 'completed', label: 'Completed' }
];
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()}`;
}
let scraping: Record<string, boolean> = $state({});
let scrapeResult: Record<string, string> = $state({});
async function scrapeNovel(novel: NovelListing) {
scraping[novel.slug] = true;
scrapeResult[novel.slug] = '';
try {
const res = await fetch('/api/scrape', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: novel.url })
});
if (res.ok) {
scrapeResult[novel.slug] = 'queued';
} else if (res.status === 409) {
scrapeResult[novel.slug] = 'busy';
} else if (res.status === 403) {
scrapeResult[novel.slug] = 'forbidden';
} else {
scrapeResult[novel.slug] = 'error';
}
} catch {
scrapeResult[novel.slug] = 'error';
} finally {
scraping[novel.slug] = false;
}
}
</script>
<svelte:head>
<title>Browse — libnovel</title>
</svelte:head>
<div class="mb-6">
<h1 class="text-2xl font-bold text-zinc-100">Browse</h1>
<p class="text-zinc-400 text-sm mt-1">Discover novels from novelfire.net</p>
</div>
<!-- Filters -->
<form method="GET" action="/browse" class="flex flex-wrap gap-3 mb-6">
<input type="hidden" name="page" value="1" />
<select
name="genre"
class="bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-1.5 focus:outline-none focus:border-amber-400"
>
{#each genres as g}
<option value={g.value} selected={data.genre === g.value}>{g.label}</option>
{/each}
</select>
<select
name="sort"
class="bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-1.5 focus:outline-none focus:border-amber-400"
>
{#each sorts as s}
<option value={s.value} selected={data.sort === s.value}>{s.label}</option>
{/each}
</select>
<select
name="status"
class="bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-1.5 focus:outline-none focus:border-amber-400"
>
{#each statuses as st}
<option value={st.value} selected={data.status === st.value}>{st.label}</option>
{/each}
</select>
<button
type="submit"
class="px-4 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
>
Filter
</button>
</form>
<!-- Novel Grid -->
{#if data.novels.length === 0}
<div class="text-center py-20 text-zinc-500">
<p class="text-lg">No novels found.</p>
<p class="text-sm mt-2">Try different filters or check back later.</p>
</div>
{:else}
<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}
<div
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 border border-zinc-700 hover:border-zinc-500 transition-colors relative"
>
<!-- Cover -->
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
{#if novel.cover}
<img
src={novel.cover}
alt={novel.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</div>
{/if}
<!-- Rank badge -->
{#if novel.rank}
<span
class="absolute top-1 left-1 text-xs px-1.5 py-0.5 rounded bg-zinc-900/80 text-amber-400 font-bold"
>
{novel.rank}
</span>
{/if}
<!-- Rating badge -->
{#if novel.rating}
<span
class="absolute top-1 right-1 text-xs px-1.5 py-0.5 rounded bg-zinc-900/80 text-zinc-300"
>
{novel.rating}
</span>
{/if}
</div>
<!-- Info -->
<div class="p-2 flex flex-col gap-1 flex-1">
<h2 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">
{novel.title}
</h2>
{#if novel.chapters}
<p class="text-xs text-zinc-500 truncate">{novel.chapters}</p>
{/if}
<!-- Admin: Scrape button -->
{#if data.isAdmin}
<div class="mt-auto pt-1">
{#if scrapeResult[novel.slug] === 'queued'}
<span class="text-xs text-emerald-400 font-medium">Queued</span>
{:else if scrapeResult[novel.slug] === 'busy'}
<span class="text-xs text-yellow-400 font-medium">Scraper busy</span>
{:else if scrapeResult[novel.slug] === 'forbidden'}
<span class="text-xs text-red-400 font-medium">Forbidden</span>
{:else if scrapeResult[novel.slug] === 'error'}
<span class="text-xs text-red-400 font-medium">Error</span>
{:else}
<button
onclick={() => scrapeNovel(novel)}
disabled={scraping[novel.slug]}
class="w-full text-xs px-2 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"
>
{scraping[novel.slug] ? 'Scraping…' : 'Scrape'}
</button>
{/if}
</div>
{/if}
</div>
</div>
{/each}
</div>
<!-- Pagination -->
<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>
{/if}