feat(ui): add Ranking page with admin refresh action and nav link

Adds /ranking route showing the cached ranking list with cover, title,
author, status, and genres. Admin users get a Refresh button that
triggers a full catalogue scrape. Adds Ranking to the nav.
This commit is contained in:
Admin
2026-03-04 00:40:39 +05:00
parent 0e868506ca
commit 2c43907e34
3 changed files with 215 additions and 1 deletions

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/state';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
@@ -24,6 +25,9 @@
</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
</a>
<div class="ml-auto flex items-center gap-4">
<span class="text-zinc-400 text-sm hidden sm:block">{data.user.username}</span>
@@ -50,7 +54,9 @@
</header>
<main class="flex-1 max-w-6xl mx-auto w-full px-4 py-8">
{@render children()}
{#key page.url.pathname + page.url.search}
{@render children()}
{/key}
</main>
<footer class="border-t border-zinc-800 text-zinc-600 text-xs text-center py-4">

View File

@@ -0,0 +1,68 @@
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' };
}
}
};

View File

@@ -0,0 +1,140 @@
<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}