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">
|
||||
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">
|
||||
|
||||
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