diff --git a/ui/src/routes/api/scrape/+server.ts b/ui/src/routes/api/scrape/+server.ts new file mode 100644 index 0000000..0e06555 --- /dev/null +++ b/ui/src/routes/api/scrape/+server.ts @@ -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 }); +}; diff --git a/ui/src/routes/browse/+page.server.ts b/ui/src/routes/browse/+page.server.ts new file mode 100644 index 0000000..d365d62 --- /dev/null +++ b/ui/src/routes/browse/+page.server.ts @@ -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' + }; +}; diff --git a/ui/src/routes/browse/+page.svelte b/ui/src/routes/browse/+page.svelte new file mode 100644 index 0000000..80544c8 --- /dev/null +++ b/ui/src/routes/browse/+page.svelte @@ -0,0 +1,239 @@ + + + + Browse — libnovel + + +
+

Browse

+

Discover novels from novelfire.net

+
+ + +
+ + + + + + + + + +
+ + +{#if data.novels.length === 0} +
+

No novels found.

+

Try different filters or check back later.

+
+{:else} +
+ {#each data.novels as novel} +
+ +
+ {#if novel.cover} + {novel.title} + {:else} +
+ + + +
+ {/if} + + + {#if novel.rank} + + {novel.rank} + + {/if} + + + {#if novel.rating} + + {novel.rating} + + {/if} +
+ + +
+

+ {novel.title} +

+ + {#if novel.chapters} +

{novel.chapters}

+ {/if} + + + {#if data.isAdmin} +
+ {#if scrapeResult[novel.slug] === 'queued'} + Queued + {:else if scrapeResult[novel.slug] === 'busy'} + Scraper busy + {:else if scrapeResult[novel.slug] === 'forbidden'} + Forbidden + {:else if scrapeResult[novel.slug] === 'error'} + Error + {:else} + + {/if} +
+ {/if} +
+
+ {/each} +
+ + +
+ {#if data.page > 1} + + Previous + + {/if} + + Page {data.page} + + {#if data.hasNext} + + Next + + {/if} +
+{/if}