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 @@
+
+
+
Discover novels from novelfire.net
+No novels found.
+Try different filters or check back later.
+{novel.chapters}
+ {/if} + + + {#if data.isAdmin} +