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:
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
import { page } from '$app/state';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import type { LayoutData } from './$types';
|
import type { LayoutData } from './$types';
|
||||||
|
|
||||||
@@ -24,6 +25,9 @@
|
|||||||
</a>
|
</a>
|
||||||
<a href="/browse" class="text-zinc-400 hover:text-zinc-100 text-sm transition-colors">
|
<a href="/browse" class="text-zinc-400 hover:text-zinc-100 text-sm transition-colors">
|
||||||
Browse
|
Browse
|
||||||
|
</a>
|
||||||
|
<a href="/ranking" class="text-zinc-400 hover:text-zinc-100 text-sm transition-colors">
|
||||||
|
Ranking
|
||||||
</a>
|
</a>
|
||||||
<div class="ml-auto flex items-center gap-4">
|
<div class="ml-auto flex items-center gap-4">
|
||||||
<span class="text-zinc-400 text-sm hidden sm:block">{data.user.username}</span>
|
<span class="text-zinc-400 text-sm hidden sm:block">{data.user.username}</span>
|
||||||
@@ -50,7 +54,9 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex-1 max-w-6xl mx-auto w-full px-4 py-8">
|
<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>
|
</main>
|
||||||
|
|
||||||
<footer class="border-t border-zinc-800 text-zinc-600 text-xs text-center py-4">
|
<footer class="border-t border-zinc-800 text-zinc-600 text-xs text-center py-4">
|
||||||
|
|||||||
68
ui/src/routes/ranking/+page.server.ts
Normal file
68
ui/src/routes/ranking/+page.server.ts
Normal 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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
140
ui/src/routes/ranking/+page.svelte
Normal file
140
ui/src/routes/ranking/+page.svelte
Normal 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}
|
||||||
Reference in New Issue
Block a user