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:
52
ui/src/routes/api/scrape/+server.ts
Normal file
52
ui/src/routes/api/scrape/+server.ts
Normal 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 });
|
||||||
|
};
|
||||||
47
ui/src/routes/browse/+page.server.ts
Normal file
47
ui/src/routes/browse/+page.server.ts
Normal 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'
|
||||||
|
};
|
||||||
|
};
|
||||||
239
ui/src/routes/browse/+page.svelte
Normal file
239
ui/src/routes/browse/+page.svelte
Normal 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}
|
||||||
Reference in New Issue
Block a user