v2 #1
@@ -24,10 +24,7 @@
|
||||
Library
|
||||
</a>
|
||||
<a href="/browse" class="text-zinc-400 hover:text-zinc-100 text-sm transition-colors">
|
||||
Browse
|
||||
</a>
|
||||
<a href="/ranking" class="text-zinc-400 hover:text-zinc-100 text-sm transition-colors">
|
||||
Ranking
|
||||
Discover
|
||||
</a>
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
<span class="text-zinc-400 text-sm hidden sm:block">{data.user.username}</span>
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import type { NovelListing } from './+page.server';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
// Filter options
|
||||
const genres = [
|
||||
@@ -28,7 +29,7 @@
|
||||
{ value: 'popular', label: 'Popular' },
|
||||
{ value: 'new', label: 'New' },
|
||||
{ value: 'update', label: 'Updated' },
|
||||
{ value: 'rank', label: 'Rank' }
|
||||
{ value: 'rank', label: 'Ranking' }
|
||||
];
|
||||
const statuses = [
|
||||
{ value: 'all', label: 'All' },
|
||||
@@ -36,6 +37,10 @@
|
||||
{ value: 'completed', label: 'Completed' }
|
||||
];
|
||||
|
||||
// When sort=rank the ranking API is used — pagination + genre/status filters
|
||||
// don't apply to that endpoint.
|
||||
const isRankView = $derived(data.sort === 'rank');
|
||||
|
||||
function buildURL(overrides: Record<string, string | number>) {
|
||||
const params = new URLSearchParams({
|
||||
page: String(data.page),
|
||||
@@ -47,6 +52,14 @@
|
||||
return `/browse?${params.toString()}`;
|
||||
}
|
||||
|
||||
// View toggle: 'grid' | 'list'. Default to 'list' when sort=rank (more detail).
|
||||
let view = $state<'grid' | 'list'>(data.sort === 'rank' ? 'list' : 'grid');
|
||||
// Keep view in sync when sort changes via filter form.
|
||||
$effect(() => {
|
||||
if (data.sort === 'rank' && view === 'grid') view = 'list';
|
||||
});
|
||||
|
||||
// Admin: per-novel scrape state (grid view)
|
||||
let scraping: Record<string, boolean> = $state({});
|
||||
let scrapeResult: Record<string, string> = $state({});
|
||||
|
||||
@@ -59,32 +72,115 @@
|
||||
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';
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Admin: refresh catalogue
|
||||
let refreshing = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Browse — libnovel</title>
|
||||
<title>Discover — 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>
|
||||
<!-- Header row -->
|
||||
<div class="mb-6 flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Discover</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">
|
||||
{#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}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<!-- View toggle -->
|
||||
<div class="flex items-center bg-zinc-800 border border-zinc-700 rounded overflow-hidden">
|
||||
<button
|
||||
onclick={() => (view = 'grid')}
|
||||
title="Grid view"
|
||||
class="px-2.5 py-1.5 transition-colors {view === 'grid'
|
||||
? 'bg-zinc-600 text-zinc-100'
|
||||
: 'text-zinc-400 hover:text-zinc-200'}"
|
||||
>
|
||||
<!-- grid icon -->
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (view = 'list')}
|
||||
title="List view"
|
||||
class="px-2.5 py-1.5 transition-colors {view === 'list'
|
||||
? 'bg-zinc-600 text-zinc-100'
|
||||
: 'text-zinc-400 hover:text-zinc-200'}"
|
||||
>
|
||||
<!-- list icon -->
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Admin: refresh catalogue -->
|
||||
{#if data.isAdmin}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/refresh"
|
||||
use:enhance={() => {
|
||||
refreshing = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
refreshing = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={refreshing}
|
||||
class="px-4 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
{refreshing ? 'Queuing…' : 'Refresh catalogue'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin flash messages -->
|
||||
{#if form}
|
||||
{#if form.status === 'queued'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-emerald-900/40 border border-emerald-700 text-emerald-300 text-sm">
|
||||
Full catalogue scrape queued. Library and ranking will update as books are processed.
|
||||
</div>
|
||||
{:else if form.status === 'busy'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-yellow-900/40 border border-yellow-700 text-yellow-300 text-sm">
|
||||
A scrape job is already running. Check back once it finishes.
|
||||
</div>
|
||||
{:else if form.status === 'error'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-red-900/40 border border-red-700 text-red-300 text-sm">
|
||||
Failed to queue scrape. Check that the scraper service is reachable.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Filters -->
|
||||
<form method="GET" action="/browse" class="flex flex-wrap gap-3 mb-6">
|
||||
<input type="hidden" name="page" value="1" />
|
||||
@@ -92,7 +188,8 @@
|
||||
<select
|
||||
name="genre"
|
||||
value={data.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"
|
||||
disabled={isRankView}
|
||||
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 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#each genres as g}
|
||||
<option value={g.value}>{g.label}</option>
|
||||
@@ -112,7 +209,8 @@
|
||||
<select
|
||||
name="status"
|
||||
value={data.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"
|
||||
disabled={isRankView}
|
||||
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 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#each statuses as st}
|
||||
<option value={st.value}>{st.label}</option>
|
||||
@@ -125,20 +223,34 @@
|
||||
>
|
||||
Filter
|
||||
</button>
|
||||
|
||||
{#if isRankView}
|
||||
<span class="self-center text-xs text-zinc-500 italic">Genre & status filters apply to Browse only</span>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<!-- Novel Grid -->
|
||||
<!-- Content -->
|
||||
{#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>
|
||||
<p class="text-lg">{isRankView ? 'No ranking data.' : 'No novels found.'}</p>
|
||||
<p class="text-sm mt-2">
|
||||
{#if isRankView}
|
||||
{#if data.isAdmin}
|
||||
Click <span class="text-amber-400">Refresh catalogue</span> 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}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
{:else if view === 'grid'}
|
||||
<!-- ── Grid view ─────────────────────────────────────────────────────── -->
|
||||
<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"
|
||||
>
|
||||
<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}
|
||||
@@ -151,30 +263,18 @@
|
||||
{: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"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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}
|
||||
@@ -182,16 +282,15 @@
|
||||
|
||||
<!-- 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}
|
||||
<h2 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{novel.title}</h2>
|
||||
{#if novel.author}
|
||||
<p class="text-xs text-zinc-500 truncate">{novel.author}</p>
|
||||
{:else if novel.chapters}
|
||||
<p class="text-xs text-zinc-500 truncate">{novel.chapters}</p>
|
||||
{/if}
|
||||
|
||||
<!-- Admin: Scrape button -->
|
||||
{#if data.isAdmin}
|
||||
<!-- Admin: per-novel scrape button -->
|
||||
{#if data.isAdmin && novel.url}
|
||||
<div class="mt-auto pt-1">
|
||||
{#if scrapeResult[novel.slug] === 'queued'}
|
||||
<span class="text-xs text-emerald-400 font-medium">Queued</span>
|
||||
@@ -217,7 +316,105 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{:else}
|
||||
<!-- ── List view ─────────────────────────────────────────────────────── -->
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each data.novels as novel}
|
||||
<div class="flex items-center gap-4 bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-3 hover:border-zinc-500 transition-colors">
|
||||
<!-- Rank / index -->
|
||||
{#if novel.rank}
|
||||
<span class="text-amber-400 font-bold text-sm w-8 shrink-0 text-right">{novel.rank}</span>
|
||||
{/if}
|
||||
|
||||
<!-- Cover thumbnail -->
|
||||
<div class="w-10 h-14 shrink-0 rounded overflow-hidden bg-zinc-900">
|
||||
{#if novel.cover}
|
||||
<img src={novel.cover} alt={novel.title} class="w-full h-full object-cover" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<svg class="w-5 h-5" 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}
|
||||
</div>
|
||||
|
||||
<!-- Title + meta -->
|
||||
<div class="flex-1 min-w-0">
|
||||
{#if novel.slug}
|
||||
<a
|
||||
href="/books/{novel.slug}"
|
||||
class="text-sm font-semibold text-zinc-100 hover:text-amber-400 transition-colors line-clamp-1"
|
||||
>
|
||||
{novel.title}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="text-sm font-semibold text-zinc-100 line-clamp-1">{novel.title}</span>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{#if novel.author}
|
||||
<span class="text-xs text-zinc-400">{novel.author}</span>
|
||||
{/if}
|
||||
{#if novel.status}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300">{novel.status}</span>
|
||||
{:else if novel.chapters}
|
||||
<span class="text-xs text-zinc-500">{novel.chapters}</span>
|
||||
{/if}
|
||||
{#if novel.rating}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-400">★ {novel.rating}</span>
|
||||
{/if}
|
||||
{#if novel.genres?.length}
|
||||
{#each novel.genres.slice(0, 3) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin: per-novel scrape button (list view) -->
|
||||
{#if data.isAdmin && novel.url}
|
||||
<div class="shrink-0">
|
||||
{#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">Busy</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="text-xs px-2.5 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 whitespace-nowrap"
|
||||
>
|
||||
{scraping[novel.slug] ? 'Scraping…' : 'Scrape'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- External link -->
|
||||
{#if novel.source_url || novel.url}
|
||||
<a
|
||||
href={novel.source_url ?? novel.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="shrink-0 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
title="Open on novelfire.net"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pagination (browse mode only) -->
|
||||
{#if !isRankView && data.novels.length > 0}
|
||||
<div class="flex items-center justify-center gap-3 mt-8">
|
||||
{#if data.page > 1}
|
||||
<a
|
||||
@@ -227,9 +424,7 @@
|
||||
Previous
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<span class="text-zinc-400 text-sm">Page {data.page}</span>
|
||||
|
||||
{#if data.hasNext}
|
||||
<a
|
||||
href={buildURL({ page: data.page + 1 })}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
|
||||
|
||||
export interface RankingItem {
|
||||
rank: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
cover: string;
|
||||
status: string;
|
||||
genres: string[];
|
||||
source_url: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
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' };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,140 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let refreshing = $state(false);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Ranking — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Ranking</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">
|
||||
{#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}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if data.isAdmin}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/refresh"
|
||||
use:enhance={() => {
|
||||
refreshing = true;
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
refreshing = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={refreshing}
|
||||
class="px-4 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
{refreshing ? 'Queuing…' : 'Refresh ranking'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if form}
|
||||
{#if form.status === 'queued'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-emerald-900/40 border border-emerald-700 text-emerald-300 text-sm">
|
||||
Full catalogue scrape queued. Ranking will update as books are processed.
|
||||
</div>
|
||||
{:else if form.status === 'busy'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-yellow-900/40 border border-yellow-700 text-yellow-300 text-sm">
|
||||
A scrape job is already running. Check back once it finishes.
|
||||
</div>
|
||||
{:else if form.status === 'error'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-red-900/40 border border-red-700 text-red-300 text-sm">
|
||||
Failed to queue scrape. Check that the scraper service is reachable.
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if data.items.length === 0}
|
||||
<div class="text-center py-20 text-zinc-500">
|
||||
<p class="text-lg">No ranking data.</p>
|
||||
<p class="text-sm mt-2">
|
||||
{#if data.isAdmin}
|
||||
Click <span class="text-amber-400">Refresh ranking</span> above to trigger a full catalogue scrape.
|
||||
{:else}
|
||||
Ask an admin to run a catalogue scrape.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each data.items as item}
|
||||
<div class="flex items-center gap-4 bg-zinc-800 border border-zinc-700 rounded-lg px-4 py-3 hover:border-zinc-500 transition-colors">
|
||||
<!-- Rank number -->
|
||||
<span class="text-amber-400 font-bold text-sm w-8 shrink-0 text-right">
|
||||
#{item.rank}
|
||||
</span>
|
||||
|
||||
<!-- Cover thumbnail -->
|
||||
<div class="w-10 h-14 shrink-0 rounded overflow-hidden bg-zinc-900">
|
||||
{#if item.cover}
|
||||
<img src={item.cover} alt={item.title} class="w-full h-full object-cover" loading="lazy" />
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center text-zinc-600">
|
||||
<svg class="w-5 h-5" 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}
|
||||
</div>
|
||||
|
||||
<!-- Title + meta -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<a
|
||||
href="/books/{item.slug}"
|
||||
class="text-sm font-semibold text-zinc-100 hover:text-amber-400 transition-colors line-clamp-1"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{#if item.author}
|
||||
<span class="text-xs text-zinc-400">{item.author}</span>
|
||||
{/if}
|
||||
{#if item.status}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300">{item.status}</span>
|
||||
{/if}
|
||||
{#if item.genres?.length}
|
||||
{#each item.genres.slice(0, 3) as genre}
|
||||
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- External link -->
|
||||
{#if item.source_url}
|
||||
<a
|
||||
href={item.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="shrink-0 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
title="Open on novelfire.net"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user