feat(v3): Caddy hardening, Watchtower, scrape fixes, docs diagrams

- Caddy: custom image with caddy-ratelimit plugin, security headers
  (X-Frame-Options, HSTS, CSP-adjacent, etc.), per-IP rate limiting on
  auth/scrape/global zones, static error pages (502/503/504), fix routing
  to remove /api/scrape/* and /api/chapter-text-preview/* direct-to-backend
  (were bypassing SvelteKit auth middleware)
- docker-compose: Caddy build context + error volume, Watchtower service
  (label-enable mode, 5 min poll), watchtower labels on backend/runner/ui
- Scraper: ScrapeChapterList uses retryGet (9 attempts, Retry-After backoff)
  to fix 429-induced chapter list failures; upTo param stops pagination early
  for range scrapes
- UI: Browse→Catalogue rename (routes, API, links), admin scrape page
  Continue/Retry buttons, +error.svelte branded error page, type cleanup
  (removed dead exports, added BookPreviewMeta/BookPreviewResponse to scraper.ts)
- Meilisearch: meta_updated field, sort=update fix, facet distribution
- Docs: reorganise into docs/d2/ and docs/mermaid/ subdirectories, update
  all diagrams to reflect Caddy/Watchtower/routing changes, add
  api-routing.d2 ownership map with auth-level colour coding, regenerate SVGs
This commit is contained in:
Admin
2026-03-22 21:10:38 +05:00
parent a85636d5db
commit 5b27d501af
60 changed files with 2485 additions and 908 deletions

View File

@@ -2,20 +2,7 @@
import { Button } from '$lib/components/ui/button';
import { Textarea } from '$lib/components/ui/textarea';
import { cn } from '$lib/utils';
interface BookComment {
id: string;
slug: string;
user_id: string;
username: string;
body: string;
upvotes: number;
downvotes: number;
created: string;
parent_id?: string;
replies?: BookComment[];
}
import type { BookComment } from '$lib/types';
let {
slug,
isLoggedIn = false,
@@ -312,7 +299,7 @@
</div>
{:else}
<p class="text-sm text-zinc-500">
<a href="/auth/login" class="text-amber-400 hover:text-amber-300 transition-colors">Log in</a>
<a href="/login" class="text-amber-400 hover:text-amber-300 transition-colors">Log in</a>
to leave a comment.
</p>
{/if}

View File

@@ -0,0 +1,71 @@
/**
* Shared types and helpers for the /api/catalogue backend response.
*
* Imported by both:
* - src/routes/catalogue/+page.server.ts (SSR page load)
* - src/routes/api/catalogue-page/+server.ts (infinite-scroll proxy)
*/
/** Shape of a single book as returned by GET /api/catalogue on the Go backend. */
export interface CatalogueBook {
slug: string;
title: string;
author: string;
cover: string;
status: string;
genres: string[];
summary: string;
total_chapters: number;
source_url: string;
ranking: number;
rating: number;
}
/** Facets returned alongside catalogue results for dynamic filter options. */
export interface CatalogueFacets {
genres: string[];
statuses: string[];
}
/** Full response shape from GET /api/catalogue. */
export interface CatalogueResponse {
books: CatalogueBook[];
page: number;
limit: number;
total: number;
has_next: boolean;
facets?: CatalogueFacets;
}
/** Normalised book shape consumed by the catalogue UI. */
export interface NovelListing {
slug: string;
title: string;
cover: string;
rank: string;
rating: string;
chapters: string;
url: string;
// enriched fields
author?: string;
status?: string;
genres?: string[];
source_url?: string;
}
/** Convert a raw CatalogueBook into the UI NovelListing shape. */
export function bookToListing(book: CatalogueBook): NovelListing {
return {
slug: book.slug,
title: book.title,
cover: book.cover,
rank: book.ranking > 0 ? `#${book.ranking}` : '',
rating: book.rating > 0 ? String(book.rating) : '',
chapters: book.total_chapters > 0 ? `${book.total_chapters} chapters` : '',
url: book.source_url ?? '',
author: book.author,
status: book.status,
genres: book.genres ?? [],
source_url: book.source_url
};
}

View File

@@ -51,7 +51,7 @@ export async function presignAvatarUrl(userId: string): Promise<string | null> {
throw new Error(`presign avatar failed: ${res.status} ${body}`);
}
const data = (await res.json()) as { url: string };
return data.url ?? null;
return data.url ? rewriteHost(data.url) : null;
}
/**

View File

@@ -46,7 +46,7 @@ export interface Progress {
updated: string;
}
export interface UserSettings {
export interface PBUserSettings {
id?: string;
session_id: string;
user_id?: string;
@@ -583,8 +583,8 @@ function settingsFilter(sessionId: string, userId?: string): string {
export async function getSettings(
sessionId: string,
userId?: string
): Promise<UserSettings | null> {
return listOne<UserSettings>('user_settings', settingsFilter(sessionId, userId));
): Promise<PBUserSettings | null> {
return listOne<PBUserSettings>('user_settings', settingsFilter(sessionId, userId));
}
export async function saveSettings(
@@ -592,12 +592,12 @@ export async function saveSettings(
settings: { autoNext: boolean; voice: string; speed: number },
userId?: string
): Promise<void> {
const existing = await listOne<UserSettings & { id: string }>(
const existing = await listOne<PBUserSettings & { id: string }>(
'user_settings',
settingsFilter(sessionId, userId)
);
const payload: Partial<UserSettings> = {
const payload: Partial<PBUserSettings> = {
session_id: sessionId,
auto_next: settings.autoNext,
voice: settings.voice,
@@ -684,6 +684,8 @@ export interface ScrapingTask {
books_found: number;
chapters_scraped: number;
chapters_skipped: number;
from_chapter: number;
to_chapter: number;
errors: number;
started: string;
finished: string;
@@ -694,6 +696,10 @@ export async function listScrapingTasks(): Promise<ScrapingTask[]> {
return listAll<ScrapingTask>('scraping_tasks', '', '-started');
}
export async function getScrapingTask(id: string): Promise<ScrapingTask | null> {
return listOne<ScrapingTask>('scraping_tasks', `id="${id}"`);
}
// ─── Audio jobs ───────────────────────────────────────────────────────────────
export interface AudioJob {
@@ -850,7 +856,7 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
// ─── Comments ─────────────────────────────────────────────────────────────────
export interface BookComment {
export interface PBBookComment {
id: string;
slug: string;
user_id: string;
@@ -881,7 +887,7 @@ export type CommentSort = 'top' | 'new';
export async function listComments(
slug: string,
sort: CommentSort = 'new'
): Promise<BookComment[]> {
): Promise<PBBookComment[]> {
const token = await getToken();
const slugEsc = slug.replace(/"/g, '\\"');
// Only top-level comments (parent_id is empty or missing)
@@ -896,7 +902,7 @@ export async function listComments(
);
if (!res.ok) return [];
const data = await res.json();
let items = (data.items ?? []) as BookComment[];
let items = (data.items ?? []) as PBBookComment[];
if (sort === 'top') {
items = items.sort((a, b) => {
const scoreB = (b.upvotes ?? 0) - (b.downvotes ?? 0);
@@ -913,7 +919,7 @@ export async function listComments(
* List replies (1-level deep) for a single parent comment.
* Always sorted oldest-first so the conversation reads naturally.
*/
export async function listReplies(parentId: string): Promise<BookComment[]> {
export async function listReplies(parentId: string): Promise<PBBookComment[]> {
const token = await getToken();
const filter = encodeURIComponent(`parent_id="${parentId.replace(/"/g, '\\"')}"`);
const res = await fetch(
@@ -922,7 +928,7 @@ export async function listReplies(parentId: string): Promise<BookComment[]> {
);
if (!res.ok) return [];
const data = await res.json();
return (data.items ?? []) as BookComment[];
return (data.items ?? []) as PBBookComment[];
}
/**
@@ -935,7 +941,7 @@ export async function createComment(
userId: string | undefined,
username: string,
parentId?: string
): Promise<BookComment> {
): Promise<PBBookComment> {
const token = await getToken();
const res = await fetch(`${PB_URL}/api/collections/book_comments/records`, {
method: 'POST',
@@ -955,7 +961,7 @@ export async function createComment(
const text = await res.text().catch(() => '');
throw new Error(`createComment failed: ${res.status} ${text}`);
}
return res.json() as Promise<BookComment>;
return res.json() as Promise<PBBookComment>;
}
/**
@@ -971,7 +977,7 @@ export async function deleteComment(commentId: string, userId: string): Promise<
headers: { Authorization: `Bearer ${token}` }
});
if (!getRes.ok) throw new Error(`Comment not found: ${commentId}`);
const comment = (await getRes.json()) as BookComment;
const comment = (await getRes.json()) as PBBookComment;
if (comment.user_id !== userId) throw new Error('Not authorized to delete this comment');
// Delete any replies first
@@ -982,7 +988,7 @@ export async function deleteComment(commentId: string, userId: string): Promise<
);
if (repliesRes.ok) {
const repliesData = await repliesRes.json();
const replies = (repliesData.items ?? []) as BookComment[];
const replies = (repliesData.items ?? []) as PBBookComment[];
await Promise.all(
replies.map((r) =>
fetch(`${PB_URL}/api/collections/book_comments/records/${r.id}`, {
@@ -1035,7 +1041,7 @@ export async function voteComment(
vote: 'up' | 'down',
sessionId: string,
userId?: string
): Promise<BookComment> {
): Promise<PBBookComment> {
const token = await getToken();
// Fetch current comment
@@ -1043,7 +1049,7 @@ export async function voteComment(
headers: { Authorization: `Bearer ${token}` }
});
if (!commentRes.ok) throw new Error(`Comment not found: ${commentId}`);
const comment = (await commentRes.json()) as BookComment;
const comment = (await commentRes.json()) as PBBookComment;
const existing = await getCommentVote(commentId, sessionId, userId);
@@ -1086,7 +1092,7 @@ export async function voteComment(
})
});
if (!patchRes.ok) throw new Error(`Failed to update vote counts on comment ${commentId}`);
return patchRes.json() as Promise<BookComment>;
return patchRes.json() as Promise<PBBookComment>;
}
/**

View File

@@ -33,4 +33,28 @@ export async function backendFetch(path: string, init?: RequestInit): Promise<Re
}
}
// ─── Response types ───────────────────────────────────────────────────────────
/**
* Metadata shape returned inside the 200 response from GET /api/book-preview/{slug}.
* Used in both the SSR page load and the API proxy to avoid duplicating the inline type.
*/
export interface BookPreviewMeta {
slug: string;
title: string;
author: string;
cover: string;
status: string;
genres: string[];
summary: string;
total_chapters: number;
source_url: string;
}
/** Full 200 response from GET /api/book-preview/{slug}. */
export interface BookPreviewResponse {
in_lib: boolean;
meta: BookPreviewMeta;
chapters: { number: number; title: string; date?: string }[];
}

View File

@@ -6,34 +6,6 @@
* safe to import in both server and client code.
*/
// ── Auth / User ──────────────────────────────────────────────────────────────
export interface AuthUser {
id: string;
username: string;
email: string;
role: 'admin' | 'user';
avatarUrl?: string;
}
// ── Books ────────────────────────────────────────────────────────────────────
export interface BookSummary {
id: string;
slug: string;
title: string;
author: string;
coverUrl: string;
status: string;
/** Reading progress 0–100, if the user has any. */
progress?: number;
}
export interface ChapterRef {
number: number;
title: string;
}
// ── Comments ─────────────────────────────────────────────────────────────────
export interface BookComment {
@@ -49,11 +21,6 @@ export interface BookComment {
replies?: BookComment[];
}
// ── Audio ────────────────────────────────────────────────────────────────────
export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed';
// ── User settings ─────────────────────────────────────────────────────────────
export interface UserSettings {

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import { page } from '$app/state';
const status = $derived(page.status);
const message = $derived(page.error?.message ?? 'Something went wrong.');
const title = $derived(
status === 404
? 'Page not found'
: status === 403
? 'Access denied'
: status === 429
? 'Too many requests'
: status >= 500
? 'Server error'
: 'Error'
);
const description = $derived(
status === 404
? "The page you're looking for doesn't exist or has been moved."
: status === 403
? "You don't have permission to access this page."
: status === 429
? 'You are sending too many requests. Please slow down and try again shortly.'
: status >= 500
? 'An unexpected error occurred on our end. Try refreshing, or come back in a moment.'
: message
);
const code = $derived(String(status));
</script>
<svelte:head>
<title>{status} — {title} · libnovel</title>
</svelte:head>
<!-- Full-viewport centred error page — no layout nav since this is +error.svelte -->
<div
class="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col items-center justify-center px-6 py-16 font-sans"
>
<!-- Large status code -->
<p class="text-[8rem] sm:text-[11rem] font-black leading-none text-zinc-800 select-none tabular-nums">
{code}
</p>
<!-- Title + description -->
<div class="mt-4 text-center max-w-md space-y-2">
<h1 class="text-2xl sm:text-3xl font-bold text-zinc-100">{title}</h1>
<p class="text-zinc-400 text-sm sm:text-base leading-relaxed">{description}</p>
</div>
<!-- Actions -->
<div class="mt-10 flex flex-wrap gap-3 justify-center">
<a
href="/"
class="px-5 py-2.5 rounded-xl bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
>
Go home
</a>
<button
onclick={() => history.back()}
class="px-5 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-200 font-semibold text-sm hover:bg-zinc-700 transition-colors"
>
Go back
</button>
</div>
<!-- Subtle branding -->
<p class="mt-16 text-xs text-zinc-700 tracking-widest uppercase select-none">libnovel</p>
</div>

View File

@@ -233,8 +233,8 @@
Library
</a>
<a
href="/browse"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/browse') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
href="/catalogue"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
>
Discover
</a>
@@ -250,15 +250,9 @@
</a>
<a
href="/admin/audio"
class="hidden sm:block text-sm transition-colors {page.url.pathname === '/admin/audio' ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin/audio') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
>
Audio cache
</a>
<a
href="/admin/audio-jobs"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin/audio-jobs') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
>
Audio jobs
Audio
</a>
{/if}
<a
@@ -318,9 +312,9 @@
Library
</a>
<a
href="/browse"
href="/catalogue"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/browse') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/catalogue') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
>
Discover
</a>
@@ -381,7 +375,7 @@
<!-- Top row: site links -->
<nav class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
<a href="/books" class="hover:text-zinc-400 transition-colors">Library</a>
<a href="/browse" class="hover:text-zinc-400 transition-colors">Discover</a>
<a href="/catalogue" class="hover:text-zinc-400 transition-colors">Discover</a>
<a
href="https://novelfire.net"
target="_blank"

View File

@@ -140,7 +140,7 @@
<p class="text-lg font-semibold text-zinc-300 mb-2">Your library is empty</p>
<p class="text-sm mb-6">Discover novels and scrape them into your library.</p>
<a
href="/browse"
href="/catalogue"
class="inline-block px-6 py-3 bg-amber-400 text-zinc-900 font-semibold rounded hover:bg-amber-300 transition-colors"
>
Discover Novels

View File

@@ -1,17 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { listAudioJobs } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user?.role !== 'admin') {
redirect(302, '/');
}
const jobs = await listAudioJobs().catch((e) => {
log.warn('admin/audio-jobs', 'failed to load audio jobs', { err: String(e) });
return [];
});
return { jobs };
export const load: PageServerLoad = async () => {
redirect(301, '/admin/audio');
};

View File

@@ -1,153 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let jobs = $state(untrack(() => data.jobs));
// ── Live-poll: refresh while any job is in-flight ────────────────────────────
let hasInFlight = $derived(jobs.some((j) => j.status === 'pending' || j.status === 'generating'));
$effect(() => {
if (!hasInFlight) return;
const id = setInterval(async () => {
const res = await fetch('/admin/audio-jobs?__data=1').catch(() => null);
if (res?.ok) {
// SvelteKit invalidateAll is cleaner — just trigger a soft navigation reload.
import('$app/navigation').then(({ invalidateAll }) => invalidateAll());
}
}, 3000);
return () => clearInterval(id);
});
// Keep local state in sync when server re-loads
$effect(() => {
jobs = data.jobs;
});
// ── Helpers ──────────────────────────────────────────────────────────────────
function statusColor(status: string) {
if (status === 'done') return 'text-green-400';
if (status === 'generating') return 'text-amber-400 animate-pulse';
if (status === 'pending') return 'text-sky-400 animate-pulse';
if (status === 'failed') return 'text-red-400';
return 'text-zinc-300';
}
function fmtDate(s: string) {
if (!s) return '—';
return new Date(s).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
}
function duration(started: string, finished: string) {
if (!started || !finished) return '—';
const ms = new Date(finished).getTime() - new Date(started).getTime();
if (ms < 0) return '—';
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
return `${m}m ${s % 60}s`;
}
// ── Search ───────────────────────────────────────────────────────────────────
let q = $state('');
let filtered = $derived(
q.trim()
? jobs.filter(
(j) =>
j.slug.toLowerCase().includes(q.toLowerCase().trim()) ||
j.voice.toLowerCase().includes(q.toLowerCase().trim()) ||
j.status.toLowerCase().includes(q.toLowerCase().trim())
)
: jobs
);
// ── Stats ────────────────────────────────────────────────────────────────────
let stats = $derived({
total: jobs.length,
done: jobs.filter((j) => j.status === 'done').length,
failed: jobs.filter((j) => j.status === 'failed').length,
inFlight: jobs.filter((j) => j.status === 'pending' || j.status === 'generating').length
});
</script>
<svelte:head>
<title>Audio jobs — libnovel admin</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-start justify-between flex-wrap gap-3">
<div>
<h1 class="text-2xl font-bold text-zinc-100">Audio jobs</h1>
<p class="text-zinc-400 text-sm mt-1">
{stats.total} total &middot;
<span class="text-green-400">{stats.done} done</span> &middot;
{#if stats.failed > 0}
<span class="text-red-400">{stats.failed} failed</span> &middot;
{/if}
{#if stats.inFlight > 0}
<span class="text-amber-400 animate-pulse">{stats.inFlight} in-flight</span>
{:else}
<span class="text-zinc-500">0 in-flight</span>
{/if}
</p>
</div>
</div>
<!-- Search -->
<input
type="search"
bind:value={q}
placeholder="Filter by slug, voice or status…"
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
{#if filtered.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center">
{q.trim() ? 'No results.' : 'No audio jobs yet.'}
</p>
{:else}
<div class="overflow-x-auto rounded-xl border border-zinc-700">
<table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-right">Ch.</th>
<th class="px-4 py-3 text-left">Voice</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-left">Started</th>
<th class="px-4 py-3 text-left">Duration</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700/50">
{#each filtered as job}
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
<td class="px-4 py-3 text-zinc-200 font-medium">
<a href="/books/{job.slug}" class="hover:text-amber-400 transition-colors">
{job.slug}
</a>
</td>
<td class="px-4 py-3 text-right text-zinc-400">{job.chapter}</td>
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{job.voice}</td>
<td class="px-4 py-3">
<span class="font-medium {statusColor(job.status)}">{job.status}</span>
</td>
<td class="px-4 py-3 text-zinc-400">{fmtDate(job.started)}</td>
<td class="px-4 py-3 text-zinc-400">{duration(job.started, job.finished)}</td>
</tr>
{#if job.error_message}
<tr class="bg-red-950/20">
<td colspan="6" class="px-4 py-2 text-xs text-red-400 font-mono"
>{job.error_message}</td
>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</div>

View File

@@ -1,6 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { listAudioCache } from '$lib/server/pocketbase';
import { listAudioCache, listAudioJobs, type AudioCacheEntry, type AudioJob } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
export const load: PageServerLoad = async ({ locals }) => {
@@ -8,10 +8,16 @@ export const load: PageServerLoad = async ({ locals }) => {
redirect(302, '/');
}
const entries = await listAudioCache().catch((e) => {
log.warn('admin/audio', 'failed to load audio cache', { err: String(e) });
return [];
});
const [entries, jobs] = await Promise.all([
listAudioCache().catch((e): AudioCacheEntry[] => {
log.warn('admin/audio', 'failed to load audio cache', { err: String(e) });
return [];
}),
listAudioJobs().catch((e): AudioJob[] => {
log.warn('admin/audio', 'failed to load audio jobs', { err: String(e) });
return [];
})
]);
return { entries };
return { entries, jobs };
};

View File

@@ -1,19 +1,42 @@
<script lang="ts">
import { untrack } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import type { AudioJob, AudioCacheEntry } from '$lib/server/pocketbase';
let { data }: { data: PageData } = $props();
let entries = $state(untrack(() => data.entries));
let entries = $state<AudioCacheEntry[]>(untrack(() => data.entries));
let jobs = $state<AudioJob[]>(untrack(() => data.jobs));
// ── Parse cache_key ─────────────────────────────────────────────────────────
// cache_key format: "slug/chapter/voice"
function parseKey(key: string) {
const parts = key.split('/');
if (parts.length >= 3) {
return { slug: parts[0], chapter: parts[1], voice: parts.slice(2).join('/') };
}
return { slug: key, chapter: '—', voice: '—' };
// Keep in sync on server reloads
$effect(() => {
entries = data.entries;
jobs = data.jobs;
});
// ── Live-poll while any job is in-flight ─────────────────────────────────────
let hasInFlight = $derived(jobs.some((j) => j.status === 'pending' || j.status === 'generating'));
$effect(() => {
if (!hasInFlight) return;
const id = setInterval(() => {
invalidateAll();
}, 3000);
return () => clearInterval(id);
});
// ── Tabs ─────────────────────────────────────────────────────────────────────
type Tab = 'jobs' | 'cache';
let activeTab = $state<Tab>('jobs');
// ── Helpers ──────────────────────────────────────────────────────────────────
function jobStatusColor(status: string) {
if (status === 'done') return 'text-green-400';
if (status === 'generating') return 'text-amber-400 animate-pulse';
if (status === 'pending') return 'text-sky-400 animate-pulse';
if (status === 'failed') return 'text-red-400';
return 'text-zinc-300';
}
function fmtDate(s: string) {
@@ -23,71 +46,237 @@
});
}
// ── Search ──────────────────────────────────────────────────────────────────
let q = $state('');
let filtered = $derived(
q.trim()
? entries.filter((e) => e.cache_key.toLowerCase().includes(q.toLowerCase().trim()))
function duration(started: string, finished: string) {
if (!started || !finished) return '—';
const ms = new Date(finished).getTime() - new Date(started).getTime();
if (ms < 0) return '—';
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
return `${m}m ${s % 60}s`;
}
// ── Audio jobs stats + filter ────────────────────────────────────────────────
let jobsQ = $state('');
let filteredJobs = $derived(
jobsQ.trim()
? jobs.filter(
(j) =>
j.slug.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
j.voice.toLowerCase().includes(jobsQ.toLowerCase().trim()) ||
j.status.toLowerCase().includes(jobsQ.toLowerCase().trim())
)
: jobs
);
let stats = $derived({
total: jobs.length,
done: jobs.filter((j) => j.status === 'done').length,
failed: jobs.filter((j) => j.status === 'failed').length,
inFlight: jobs.filter((j) => j.status === 'pending' || j.status === 'generating').length
});
// ── Audio cache filter ───────────────────────────────────────────────────────
function parseCacheKey(key: string) {
const parts = key.split('/');
if (parts.length >= 3) {
return { slug: parts[0], chapter: parts[1], voice: parts.slice(2).join('/') };
}
return { slug: key, chapter: '—', voice: '—' };
}
let cacheQ = $state('');
let filteredCache = $derived(
cacheQ.trim()
? entries.filter((e) => e.cache_key.toLowerCase().includes(cacheQ.toLowerCase().trim()))
: entries
);
</script>
<svelte:head>
<title>Audio cache — libnovel admin</title>
<title>Audio — libnovel admin</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-zinc-100">Audio cache</h1>
<p class="text-zinc-400 text-sm mt-1">{entries.length} cached audio file{entries.length !== 1 ? 's' : ''}</p>
<h1 class="text-2xl font-bold text-zinc-100">Audio</h1>
<p class="text-zinc-400 text-sm mt-1">
{stats.total} job{stats.total !== 1 ? 's' : ''} &middot;
<span class="text-green-400">{stats.done} done</span>
{#if stats.failed > 0}
&middot; <span class="text-red-400">{stats.failed} failed</span>
{/if}
{#if stats.inFlight > 0}
&middot; <span class="text-amber-400 animate-pulse">{stats.inFlight} in-flight</span>
{/if}
&middot; {entries.length} cached file{entries.length !== 1 ? 's' : ''}
</p>
</div>
<!-- Search -->
<input
type="search"
bind:value={q}
placeholder="Filter by slug, chapter or voice…"
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
<!-- Tabs -->
<div class="flex gap-1 bg-zinc-800 rounded-lg p-1 w-fit border border-zinc-700">
<button
onclick={() => (activeTab = 'jobs')}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{activeTab === 'jobs' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'}"
>
Jobs
{#if stats.inFlight > 0}
<span class="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-amber-400 text-zinc-900 text-[10px] font-bold">
{stats.inFlight}
</span>
{/if}
</button>
<button
onclick={() => (activeTab = 'cache')}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{activeTab === 'cache' ? 'bg-zinc-700 text-zinc-100' : 'text-zinc-400 hover:text-zinc-200'}"
>
Cache
</button>
</div>
{#if filtered.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center">
{q.trim() ? 'No results.' : 'Audio cache is empty.'}
</p>
{:else}
<div class="overflow-x-auto rounded-xl border border-zinc-700">
<table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-left">Chapter</th>
<th class="px-4 py-3 text-left">Voice</th>
<th class="px-4 py-3 text-left">Filename</th>
<th class="px-4 py-3 text-left">Updated</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700/50">
{#each filtered as entry}
{@const parts = parseKey(entry.cache_key)}
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
<td class="px-4 py-3 text-zinc-200 font-medium">
<a
href="/books/{parts.slug}"
class="hover:text-amber-400 transition-colors"
>
{parts.slug}
</a>
</td>
<td class="px-4 py-3 text-zinc-400">{parts.chapter}</td>
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{parts.voice}</td>
<td class="px-4 py-3 text-zinc-500 font-mono text-xs truncate max-w-[14rem]" title={entry.filename}>
{entry.filename}
</td>
<td class="px-4 py-3 text-zinc-400">{fmtDate(entry.updated)}</td>
<!-- ── Audio Jobs tab ─────────────────────────────────────────────────────── -->
{#if activeTab === 'jobs'}
<input
type="search"
bind:value={jobsQ}
placeholder="Filter by slug, voice or status…"
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
{#if filteredJobs.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center">
{jobsQ.trim() ? 'No matching jobs.' : 'No audio jobs yet.'}
</p>
{:else}
<!-- Desktop table -->
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700">
<table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-right">Ch.</th>
<th class="px-4 py-3 text-left">Voice</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-left">Started</th>
<th class="px-4 py-3 text-left">Duration</th>
</tr>
{/each}
</tbody>
</table>
</div>
</thead>
<tbody class="divide-y divide-zinc-700/50">
{#each filteredJobs as job}
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
<td class="px-4 py-3 text-zinc-200 font-medium">
<a href="/books/{job.slug}" class="hover:text-amber-400 transition-colors">{job.slug}</a>
</td>
<td class="px-4 py-3 text-right text-zinc-400">{job.chapter}</td>
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{job.voice}</td>
<td class="px-4 py-3">
<span class="font-medium {jobStatusColor(job.status)}">{job.status}</span>
</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(job.started)}</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{duration(job.started, job.finished)}</td>
</tr>
{#if job.error_message}
<tr class="bg-red-950/20">
<td colspan="6" class="px-4 py-2 text-xs text-red-400 font-mono">{job.error_message}</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
<!-- Mobile cards -->
<div class="sm:hidden space-y-3">
{#each filteredJobs as job}
<div class="bg-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2">
<div class="flex items-start justify-between gap-2">
<a href="/books/{job.slug}" class="text-zinc-200 font-medium hover:text-amber-400 transition-colors truncate">
{job.slug}
</a>
<span class="shrink-0 text-xs font-semibold {jobStatusColor(job.status)}">{job.status}</span>
</div>
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-zinc-500">Chapter</span><span class="text-zinc-400 text-right">{job.chapter}</span>
<span class="text-zinc-500">Voice</span><span class="text-zinc-400 font-mono text-right truncate">{job.voice}</span>
<span class="text-zinc-500">Started</span><span class="text-zinc-400 text-right">{fmtDate(job.started)}</span>
<span class="text-zinc-500">Duration</span><span class="text-zinc-400 text-right">{duration(job.started, job.finished)}</span>
</div>
{#if job.error_message}
<p class="text-xs text-red-400 font-mono break-all">{job.error_message}</p>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
<!-- ── Audio Cache tab ───────────────────────────────────────────────────── -->
{#if activeTab === 'cache'}
<input
type="search"
bind:value={cacheQ}
placeholder="Filter by slug, chapter or voice…"
class="w-full max-w-sm bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
{#if filteredCache.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center">
{cacheQ.trim() ? 'No results.' : 'Audio cache is empty.'}
</p>
{:else}
<!-- Desktop table -->
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700">
<table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Book</th>
<th class="px-4 py-3 text-left">Chapter</th>
<th class="px-4 py-3 text-left">Voice</th>
<th class="px-4 py-3 text-left">Filename</th>
<th class="px-4 py-3 text-left">Updated</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700/50">
{#each filteredCache as entry}
{@const parts = parseCacheKey(entry.cache_key)}
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
<td class="px-4 py-3 text-zinc-200 font-medium">
<a href="/books/{parts.slug}" class="hover:text-amber-400 transition-colors">{parts.slug}</a>
</td>
<td class="px-4 py-3 text-zinc-400">{parts.chapter}</td>
<td class="px-4 py-3 text-zinc-400 font-mono text-xs">{parts.voice}</td>
<td class="px-4 py-3 text-zinc-500 font-mono text-xs truncate max-w-[14rem]" title={entry.filename}>
{entry.filename}
</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(entry.updated)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile cards -->
<div class="sm:hidden space-y-3">
{#each filteredCache as entry}
{@const parts = parseCacheKey(entry.cache_key)}
<div class="bg-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2">
<a href="/books/{parts.slug}" class="text-zinc-200 font-medium hover:text-amber-400 transition-colors block truncate">
{parts.slug}
</a>
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-zinc-500">Chapter</span><span class="text-zinc-400 text-right">{parts.chapter}</span>
<span class="text-zinc-500">Voice</span><span class="text-zinc-400 font-mono text-right truncate">{parts.voice}</span>
<span class="text-zinc-500">Updated</span><span class="text-zinc-400 text-right">{fmtDate(entry.updated)}</span>
</div>
{#if entry.filename}
<p class="text-xs text-zinc-500 font-mono truncate" title={entry.filename}>{entry.filename}</p>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
</div>

View File

@@ -9,7 +9,6 @@
// ── Live-poll status ────────────────────────────────────────────────────────
let running = $state(untrack(() => data.running));
let tasks = $state(untrack(() => data.tasks));
let polling = $state(false);
// Poll every 5 s while a job is running
$effect(() => {
@@ -20,7 +19,6 @@
const body = await res.json().catch(() => null);
running = body?.running ?? false;
if (!running) {
// Refresh tasks list once job finishes
await invalidateAll();
}
}
@@ -34,28 +32,27 @@
tasks = data.tasks;
});
// ── Trigger scrape ──────────────────────────────────────────────────────────
// ── Single book scrape ──────────────────────────────────────────────────────
let scrapeUrl = $state('');
let scrapeError = $state('');
let scraping = $state(false);
async function triggerScrape(url?: string) {
if (running || scraping) return;
async function triggerBookScrape(url: string) {
if (running || scraping || !url.trim()) return;
scraping = true;
scrapeError = '';
try {
const body = url ? { url } : {};
const res = await fetch('/api/scrape', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
body: JSON.stringify({ url: url.trim() })
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
scrapeError = data.error ?? data.message ?? `Error ${res.status}`;
const d = await res.json().catch(() => ({}));
scrapeError = d.error ?? d.message ?? `Error ${res.status}`;
} else {
running = true;
if (url) scrapeUrl = '';
scrapeUrl = '';
}
} catch {
scrapeError = 'Network error.';
@@ -64,8 +61,72 @@
}
}
// ── Range scrape ────────────────────────────────────────────────────────────
let rangeUrl = $state('');
let rangeFrom = $state<number | null>(null);
let rangeTo = $state<number | null>(null);
let rangeError = $state('');
let ranging = $state(false);
async function triggerRangeScrape() {
if (running || ranging || !rangeUrl.trim() || rangeFrom === null) return;
ranging = true;
rangeError = '';
try {
const body: Record<string, unknown> = { url: rangeUrl.trim(), from: rangeFrom };
if (rangeTo !== null) body.to = rangeTo;
const res = await fetch('/api/scrape/range', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
rangeError = d.error ?? d.message ?? `Error ${res.status}`;
} else {
running = true;
rangeUrl = '';
rangeFrom = null;
rangeTo = null;
}
} catch {
rangeError = 'Network error.';
} finally {
ranging = false;
}
}
// ── Continue / Retry task ───────────────────────────────────────────────────
function scrollToRangeForm() {
document.getElementById('range-form')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function scrollToBookForm() {
document.getElementById('book-form')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function continueTask(task: ScrapingTask) {
// Re-enqueue a book_range from where it left off
rangeUrl = task.target_url ?? '';
rangeFrom = (task.from_chapter ?? 1) + (task.chapters_scraped ?? 0);
rangeTo = task.to_chapter > 0 ? task.to_chapter : null;
scrollToRangeForm();
}
function retryTask(task: ScrapingTask) {
if (task.kind === 'book_range') {
rangeUrl = task.target_url ?? '';
rangeFrom = task.from_chapter ?? 1;
rangeTo = task.to_chapter > 0 ? task.to_chapter : null;
scrollToRangeForm();
} else {
// book / catalogue — just pre-fill the single-book form
scrapeUrl = task.target_url ?? '';
scrollToBookForm();
}
}
// ── Cancel task ─────────────────────────────────────────────────────────────
// Tracks which task IDs are currently being cancelled (to disable the button).
let cancellingIds = $state(new Set<string>());
let cancelErrors: Record<string, string> = $state({});
@@ -79,7 +140,6 @@
const body = await res.json().catch(() => ({}));
cancelErrors = { ...cancelErrors, [id]: body.error ?? body.message ?? `Error ${res.status}` };
} else {
// Optimistically flip status in the local list so the button disappears immediately.
tasks = tasks.map((t: ScrapingTask) => (t.id === id ? { ...t, status: 'cancelled' } : t));
}
} catch {
@@ -89,6 +149,19 @@
}
}
// ── Table filter ────────────────────────────────────────────────────────────
let q = $state('');
let filtered = $derived(
q.trim()
? tasks.filter(
(t: ScrapingTask) =>
t.kind.toLowerCase().includes(q.toLowerCase()) ||
t.status.toLowerCase().includes(q.toLowerCase()) ||
(t.target_url ?? '').toLowerCase().includes(q.toLowerCase())
)
: tasks
);
// ── Helpers ─────────────────────────────────────────────────────────────────
function statusColor(status: string) {
if (status === 'done') return 'text-green-400';
@@ -114,6 +187,16 @@
const m = Math.floor(s / 60);
return `${m}m ${s % 60}s`;
}
// Popular novelfire genres for quick-scrape links
const quickScrapes = [
{ label: 'Action', url: 'https://novelfire.net/genre/action' },
{ label: 'Fantasy', url: 'https://novelfire.net/genre/fantasy' },
{ label: 'Romance', url: 'https://novelfire.net/genre/romance' },
{ label: 'System', url: 'https://novelfire.net/genre/system' },
{ label: 'Isekai', url: 'https://novelfire.net/genre/isekai' },
{ label: 'Martial Arts', url: 'https://novelfire.net/genre/martial-arts' },
];
</script>
<svelte:head>
@@ -121,6 +204,7 @@
</svelte:head>
<div class="space-y-8">
<!-- Header -->
<div class="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 class="text-2xl font-bold text-zinc-100">Scrape tasks</h1>
@@ -133,106 +217,251 @@
{/if}
</p>
</div>
</div>
<!-- Trigger controls -->
<div class="flex flex-wrap gap-3 items-start">
<button
onclick={() => triggerScrape()}
disabled={running || scraping}
class="px-4 py-2 rounded-lg bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors disabled:opacity-50"
>
Full catalogue scrape
</button>
<!-- Scrape controls -->
<div class="grid gap-4 sm:grid-cols-2">
<!-- Single book -->
<div id="book-form" class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
<h2 class="text-sm font-semibold text-zinc-300">Scrape a single book</h2>
<div class="flex gap-2">
<input
type="url"
bind:value={scrapeUrl}
placeholder="https://novelfire.net/book/…"
class="flex-1 min-w-0 bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
<button
onclick={() => triggerBookScrape(scrapeUrl)}
disabled={!scrapeUrl.trim() || running || scraping}
class="shrink-0 px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-50"
>
{scraping ? 'Queuing…' : 'Scrape'}
</button>
</div>
{#if scrapeError}
<p class="text-sm text-red-400">{scrapeError}</p>
{/if}
</div>
<!-- Range scrape -->
<div id="range-form" class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
<h2 class="text-sm font-semibold text-zinc-300">Scrape chapter range</h2>
<input
type="url"
bind:value={rangeUrl}
placeholder="https://novelfire.net/book/…"
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
<div class="flex gap-2">
<input
type="number"
bind:value={rangeFrom}
min="1"
placeholder="From ch."
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
<input
type="number"
bind:value={rangeTo}
min="1"
placeholder="To ch. (opt)"
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
<button
onclick={triggerRangeScrape}
disabled={!rangeUrl.trim() || rangeFrom === null || running || ranging}
class="shrink-0 px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-50"
>
{ranging ? 'Queuing…' : 'Go'}
</button>
</div>
{#if rangeError}
<p class="text-sm text-red-400">{rangeError}</p>
{/if}
</div>
</div>
<!-- Single book scrape -->
<!-- Quick-scrape genre links -->
<div class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
<h2 class="text-sm font-semibold text-zinc-300">Scrape a single book</h2>
<div class="flex gap-2">
<input
type="url"
bind:value={scrapeUrl}
placeholder="https://novelfire.net/book/..."
class="flex-1 bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
<button
onclick={() => triggerScrape(scrapeUrl.trim() || undefined)}
disabled={!scrapeUrl.trim() || running || scraping}
class="px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-50"
<h2 class="text-sm font-semibold text-zinc-300">Quick genre refresh</h2>
<p class="text-xs text-zinc-500">Paste one of these into the single-book scraper to re-index a genre, or use them as starting points for range scrapes.</p>
<div class="flex flex-wrap gap-2">
{#each quickScrapes as qs}
<button
onclick={() => { scrapeUrl = qs.url; }}
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-300 border border-zinc-600 hover:border-amber-400/60 hover:text-amber-300 transition-colors"
>
{qs.label}
</button>
{/each}
<a
href="https://novelfire.net"
target="_blank"
rel="noopener noreferrer"
class="px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700/50 text-zinc-400 border border-zinc-600/50 hover:text-amber-300 hover:border-amber-400/40 transition-colors"
>
Scrape
</button>
Browse novelfire.net ↗
</a>
</div>
{#if scrapeError}
<p class="text-sm text-red-400">{scrapeError}</p>
{/if}
</div>
<!-- Tasks table -->
{#if tasks.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center">No scrape tasks yet.</p>
{:else}
<div class="overflow-x-auto rounded-xl border border-zinc-700">
<table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Kind</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-right">Books</th>
<th class="px-4 py-3 text-right">Chapters</th>
<th class="px-4 py-3 text-right">Skipped</th>
<th class="px-4 py-3 text-right">Errors</th>
<th class="px-4 py-3 text-left">Started</th>
<th class="px-4 py-3 text-left">Duration</th>
<th class="px-4 py-3 text-left">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700/50">
{#each tasks as task}
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
<td class="px-4 py-3 font-mono text-xs text-zinc-300">
{task.kind}
{#if task.target_url}
<br />
<span class="text-zinc-500 truncate max-w-[16rem] block" title={task.target_url}>
{task.target_url.replace('https://novelfire.net/book/', '')}
</span>
{/if}
</td>
<td class="px-4 py-3">
<span class="font-medium {statusColor(task.status)}">{task.status}</span>
</td>
<td class="px-4 py-3 text-right text-zinc-300">{task.books_found ?? 0}</td>
<td class="px-4 py-3 text-right text-zinc-300">{task.chapters_scraped ?? 0}</td>
<td class="px-4 py-3 text-right text-zinc-400">{task.chapters_skipped ?? 0}</td>
<td class="px-4 py-3 text-right {task.errors > 0 ? 'text-red-400' : 'text-zinc-400'}">{task.errors ?? 0}</td>
<td class="px-4 py-3 text-zinc-400">{fmtDate(task.started)}</td>
<td class="px-4 py-3 text-zinc-400">{duration(task.started, task.finished)}</td>
<td class="px-4 py-3">
{#if task.status === 'pending'}
<button
onclick={() => cancelTask(task.id)}
disabled={cancellingIds.has(task.id)}
class="px-2 py-1 rounded text-xs font-medium bg-zinc-700 text-zinc-300 hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
title="Cancel this task"
>
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel'}
</button>
{#if cancelErrors[task.id]}
<p class="text-xs text-red-400 mt-1">{cancelErrors[task.id]}</p>
{/if}
{/if}
</td>
</tr>
{#if task.error_message}
<tr class="bg-red-950/20">
<td colspan="9" class="px-4 py-2 text-xs text-red-400 font-mono">{task.error_message}</td>
</tr>
{/if}
{/each}
</tbody>
</table>
<div class="space-y-3">
<div class="flex items-center gap-3 flex-wrap">
<h2 class="text-lg font-semibold text-zinc-100 flex-1">Task history</h2>
<input
type="search"
bind:value={q}
placeholder="Filter by kind, status or URL…"
class="w-full max-w-xs bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
/>
</div>
{/if}
{#if filtered.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center">
{q.trim() ? 'No matching tasks.' : 'No scrape tasks yet.'}
</p>
{:else}
<!-- Desktop table -->
<div class="hidden sm:block overflow-x-auto rounded-xl border border-zinc-700">
<table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Kind / URL</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-right">Books</th>
<th class="px-4 py-3 text-right">Chapters</th>
<th class="px-4 py-3 text-right">Skipped</th>
<th class="px-4 py-3 text-right">Errors</th>
<th class="px-4 py-3 text-left">Started</th>
<th class="px-4 py-3 text-left">Duration</th>
<th class="px-4 py-3 text-left">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700/50">
{#each filtered as task}
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
<td class="px-4 py-3 font-mono text-xs text-zinc-300">
{task.kind}
{#if task.target_url}
<br />
<span class="text-zinc-500 truncate max-w-[16rem] block" title={task.target_url}>
{task.target_url.replace('https://novelfire.net/book/', '')}
</span>
{/if}
</td>
<td class="px-4 py-3">
<span class="font-medium {statusColor(task.status)}">{task.status}</span>
</td>
<td class="px-4 py-3 text-right text-zinc-300">{task.books_found ?? 0}</td>
<td class="px-4 py-3 text-right text-zinc-300">{task.chapters_scraped ?? 0}</td>
<td class="px-4 py-3 text-right text-zinc-400">{task.chapters_skipped ?? 0}</td>
<td class="px-4 py-3 text-right {task.errors > 0 ? 'text-red-400' : 'text-zinc-400'}">{task.errors ?? 0}</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{fmtDate(task.started)}</td>
<td class="px-4 py-3 text-zinc-400 whitespace-nowrap">{duration(task.started, task.finished)}</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1.5">
{#if task.status === 'pending'}
<button
onclick={() => cancelTask(task.id)}
disabled={cancellingIds.has(task.id)}
class="px-2 py-1 rounded text-xs font-medium bg-zinc-700 text-zinc-300 hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
>
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel'}
</button>
{/if}
{#if task.kind === 'book_range' && task.status !== 'pending' && task.status !== 'running' && (task.chapters_scraped ?? 0) > 0}
<button
onclick={() => continueTask(task)}
class="px-2 py-1 rounded text-xs font-medium bg-amber-900/60 text-amber-300 hover:bg-amber-800/60 transition-colors"
>
Continue ▶
</button>
{/if}
{#if (task.status === 'failed' || task.status === 'cancelled') && task.kind !== 'catalogue'}
<button
onclick={() => retryTask(task)}
class="px-2 py-1 rounded text-xs font-medium bg-sky-900/60 text-sky-300 hover:bg-sky-800/60 transition-colors"
>
Retry ↺
</button>
{/if}
{#if cancelErrors[task.id]}
<p class="text-xs text-red-400 mt-1 w-full">{cancelErrors[task.id]}</p>
{/if}
</div>
</td>
</tr>
{#if task.error_message}
<tr class="bg-red-950/20">
<td colspan="9" class="px-4 py-2 text-xs text-red-400 font-mono">{task.error_message}</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
<!-- Mobile cards -->
<div class="sm:hidden space-y-3">
{#each filtered as task}
<div class="bg-zinc-900 rounded-xl border border-zinc-700 p-4 space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<span class="font-mono text-xs text-zinc-300">{task.kind}</span>
{#if task.target_url}
<p class="text-xs text-zinc-500 truncate mt-0.5" title={task.target_url}>
{task.target_url.replace('https://novelfire.net/book/', '')}
</p>
{/if}
</div>
<span class="shrink-0 text-xs font-semibold {statusColor(task.status)}">{task.status}</span>
</div>
<div class="grid grid-cols-2 gap-1 text-xs">
<span class="text-zinc-500">Books</span><span class="text-zinc-300 text-right">{task.books_found ?? 0}</span>
<span class="text-zinc-500">Chapters</span><span class="text-zinc-300 text-right">{task.chapters_scraped ?? 0}</span>
<span class="text-zinc-500">Skipped</span><span class="text-zinc-400 text-right">{task.chapters_skipped ?? 0}</span>
<span class="text-zinc-500">Errors</span><span class="{task.errors > 0 ? 'text-red-400' : 'text-zinc-400'} text-right">{task.errors ?? 0}</span>
<span class="text-zinc-500">Started</span><span class="text-zinc-400 text-right">{fmtDate(task.started)}</span>
<span class="text-zinc-500">Duration</span><span class="text-zinc-400 text-right">{duration(task.started, task.finished)}</span>
</div>
{#if task.error_message}
<p class="text-xs text-red-400 font-mono break-all">{task.error_message}</p>
{/if}
<div class="flex flex-wrap gap-2">
{#if task.status === 'pending'}
<button
onclick={() => cancelTask(task.id)}
disabled={cancellingIds.has(task.id)}
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-zinc-700 text-zinc-300 hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
>
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel task'}
</button>
{/if}
{#if task.kind === 'book_range' && task.status !== 'pending' && task.status !== 'running' && (task.chapters_scraped ?? 0) > 0}
<button
onclick={() => continueTask(task)}
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-amber-900/60 text-amber-300 hover:bg-amber-800/60 transition-colors"
>
Continue ▶
</button>
{/if}
{#if (task.status === 'failed' || task.status === 'cancelled') && task.kind !== 'catalogue'}
<button
onclick={() => retryTask(task)}
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-sky-900/60 text-sky-300 hover:bg-sky-800/60 transition-colors"
>
Retry ↺
</button>
{/if}
{#if cancelErrors[task.id]}
<p class="text-xs text-red-400 w-full">{cancelErrors[task.id]}</p>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>

View File

@@ -2,7 +2,7 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getBook, listChapterIdx, getProgress, isBookSaved } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper';
/**
* GET /api/book/[slug]
@@ -61,21 +61,7 @@ export const GET: RequestHandler = async ({ params, locals }) => {
}
// 200 — book was already in library
const preview: {
in_lib: boolean;
meta: {
slug: string;
title: string;
author: string;
cover: string;
status: string;
genres: string[];
summary: string;
total_chapters: number;
source_url: string;
};
chapters: { number: number; title: string; date?: string }[];
} = await res.json();
const preview: BookPreviewResponse = await res.json();
const previewBook = {
id: '',

View File

@@ -1,76 +0,0 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
/**
* GET /api/browse-page?page=2&genre=all&sort=popular&status=all&q=
*
* Thin proxy to the Go backend's /api/catalogue endpoint.
* Used by the infinite-scroll browse page to append subsequent pages
* without a full SSR navigation.
*
* Normalises the catalogue response to the { novels, page, hasNext } shape
* expected by the client-side infinite scroll in +page.svelte.
*/
export const GET: RequestHandler = async ({ url }) => {
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 q = url.searchParams.get('q') ?? '';
const params = new URLSearchParams({ page, genre, sort, status });
if (q.trim().length >= 2) {
params.set('q', q.trim());
}
try {
const res = await backendFetch(`/api/catalogue?${params.toString()}`);
if (!res.ok) {
log.error('browse-page', 'backend returned error', { status: res.status });
throw error(502, `Browse fetch failed: ${res.status}`);
}
const data: {
books: Array<{
slug: string;
title: string;
author: string;
cover: string;
status: string;
genres: string[];
total_chapters: number;
source_url: string;
ranking: number;
rating: number;
}>;
page: number;
has_next: boolean;
} = await res.json();
// Normalise to the shape the +page.svelte client-side fetch expects.
const novels = (data.books ?? []).map((book) => ({
slug: book.slug,
title: book.title,
cover: book.cover,
rank: book.ranking > 0 ? `#${book.ranking}` : '',
rating: book.rating > 0 ? String(book.rating) : '',
chapters: book.total_chapters > 0 ? `${book.total_chapters} chapters` : '',
url: book.source_url ?? '',
author: book.author,
status: book.status,
genres: book.genres ?? [],
source_url: book.source_url
}));
return json({
novels,
page: data.page ?? (parseInt(page, 10) || 1),
hasNext: data.has_next ?? false
});
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
log.error('browse-page', 'network error', { err: String(e) });
throw error(502, 'Could not reach browse service');
}
};

View File

@@ -0,0 +1,47 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
import { bookToListing, type CatalogueResponse } from '$lib/server/catalogue';
/**
* GET /api/catalogue-page?page=2&genre=all&sort=popular&status=all&q=
*
* Thin proxy to the Go backend's /api/catalogue endpoint.
* Used by the infinite-scroll catalogue page to append subsequent pages
* without a full SSR navigation.
*
* Returns { novels, page, hasNext } — the shape expected by the client-side
* infinite scroll in +page.svelte.
*/
export const GET: RequestHandler = async ({ url }) => {
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 q = url.searchParams.get('q') ?? '';
const params = new URLSearchParams({ page, genre, sort, status });
if (q.trim().length >= 2) {
params.set('q', q.trim());
}
try {
const res = await backendFetch(`/api/catalogue?${params.toString()}`);
if (!res.ok) {
log.error('catalogue-page', 'backend returned error', { status: res.status });
throw error(502, `Catalogue fetch failed: ${res.status}`);
}
const data: CatalogueResponse = await res.json();
return json({
novels: (data.books ?? []).map(bookToListing),
page: data.page ?? (parseInt(page, 10) || 1),
hasNext: data.has_next ?? false
});
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
log.error('catalogue-page', 'network error', { err: String(e) });
throw error(502, 'Could not reach catalogue service');
}
};

View File

@@ -1,65 +1,56 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { presignAvatarUploadUrl, presignAvatarUrl } from '$lib/server/minio';
import { presignAvatarUrl } from '$lib/server/minio';
import { updateUserAvatarUrl, getUserByUsername } from '$lib/server/pocketbase';
import { backendFetch } from '$lib/server/scraper';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
/**
* POST /api/profile/avatar
* Body: JSON { mime_type: "image/jpeg" | "image/png" | "image/webp" }
* Body: raw image bytes (Content-Type: image/jpeg | image/png | image/webp)
*
* Returns a short-lived presigned PUT URL pointing at MinIO (public endpoint)
* so the client can upload the image bytes directly, bypassing the server.
* After the PUT completes, the client must call PATCH /api/profile/avatar
* with the returned key to record it in PocketBase.
*
* Returns: { upload_url: string, key: string }
*/
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) error(401, 'Not authenticated');
let mimeType = 'image/jpeg';
try {
const body = await request.json();
if (body?.mime_type) mimeType = body.mime_type;
} catch {
// default to jpeg if body is missing/invalid
}
if (!ALLOWED_TYPES.includes(mimeType)) {
error(400, `Unsupported image type: ${mimeType}. Allowed: jpeg, png, webp`);
}
const { uploadUrl, key } = await presignAvatarUploadUrl(locals.user.id, mimeType);
return json({ upload_url: uploadUrl, key });
};
/**
* PATCH /api/profile/avatar
* Body: JSON { key: string }
*
* Called after the client has successfully PUT the image to MinIO via the
* presigned URL. Records the object key in PocketBase and returns a fresh
* Uploads the image to MinIO via the Go backend (server-to-server, no browser
* → MinIO direct upload), records the key in PocketBase, and returns a fresh
* presigned GET URL for immediate display.
*
* Returns: { avatar_url: string | null }
*/
export const PATCH: RequestHandler = async ({ request, locals }) => {
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) error(401, 'Not authenticated');
let key: string | undefined;
try {
const body = await request.json();
if (typeof body?.key === 'string') key = body.key;
} catch {
error(400, 'Invalid JSON body');
const ct = request.headers.get('Content-Type') ?? '';
// Strip parameters (e.g. "image/jpeg; charset=utf-8" → "image/jpeg")
const mimeType = ct.split(';')[0].trim();
if (!ALLOWED_TYPES.includes(mimeType)) {
error(400, `Unsupported image type. Allowed: image/jpeg, image/png, image/webp`);
}
if (!key) error(400, 'Missing "key" field');
// Read the raw body
const blob = await request.arrayBuffer();
if (blob.byteLength === 0) error(400, 'Empty image body');
if (blob.byteLength > 5 * 1024 * 1024) error(413, 'Image too large (max 5 MiB)');
// Forward directly to Go backend — server-to-server, so internal MinIO is reachable.
const uploadRes = await backendFetch(
`/api/avatar-upload/${encodeURIComponent(locals.user.id)}`,
{
method: 'PUT',
headers: { 'Content-Type': mimeType },
body: blob
}
);
if (!uploadRes.ok) {
const text = await uploadRes.text().catch(() => '');
error(uploadRes.status as 400 | 500, `Upload failed: ${text || uploadRes.statusText}`);
}
const { key } = (await uploadRes.json()) as { key: string };
// Record object key in PocketBase.
await updateUserAvatarUrl(locals.user.id, key);
// Return a fresh presigned GET URL for immediate display.
const avatarUrl = await presignAvatarUrl(locals.user.id);
return json({ avatar_url: avatarUrl });
};

View File

@@ -0,0 +1,19 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getScrapingTask } from '$lib/server/pocketbase';
/**
* GET /api/scrape/task/[id]
*
* Returns { id, status, error_message } for a single scraping task.
* Used by the book detail page to poll for task completion.
*/
export const GET: RequestHandler = async ({ params }) => {
const { id } = params;
if (!id) throw error(400, 'Missing task id');
const task = await getScrapingTask(id).catch(() => null);
if (!task) throw error(404, 'Task not found');
return json({ id: task.id, status: task.status, error_message: task.error_message ?? '' });
};

View File

@@ -31,7 +31,7 @@
<p class="text-lg">Your library is empty.</p>
<p class="text-sm mt-2">
Books you start reading or save from
<a href="/browse" class="text-amber-400 hover:text-amber-300 transition-colors">Discover</a>
<a href="/catalogue" class="text-amber-400 hover:text-amber-300 transition-colors">Discover</a>
will appear here.
</p>
</div>

View File

@@ -2,7 +2,7 @@ import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getBook, listChapterIdx, getProgress, isBookSaved } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper';
export const load: PageServerLoad = async ({ params, locals }) => {
const { slug } = params;
@@ -70,21 +70,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
}
// 200 — book was already in library when backend checked
const preview: {
in_lib: boolean;
meta: {
slug: string;
title: string;
author: string;
cover: string;
status: string;
genres: string[];
summary: string;
total_chapters: number;
source_url: string;
};
chapters: { number: number; title: string; date?: string }[];
} = await res.json();
const preview: BookPreviewResponse = await res.json();
const previewBook = {
id: '',

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { untrack } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import CommentsSection from '$lib/components/CommentsSection.svelte';
@@ -93,6 +94,33 @@
// ── Admin panel expand/collapse ───────────────────────────────────────────
let adminOpen = $state(false);
// ── Auto-poll when scrape task is in flight ───────────────────────────────
// When the backend enqueues a scrape for an unseen book, the page shows a
// spinner. Poll every 3 s until the task reaches "done" or "failed", then
// reload the full page data so chapters appear automatically.
$effect(() => {
if (!data.scraping || !data.taskId) return;
const INTERVAL_MS = 3000;
const id = data.taskId;
const timer = setInterval(async () => {
try {
const res = await fetch(`/api/scrape/task/${encodeURIComponent(id)}`);
if (!res.ok) return;
const body: { id: string; status: string } = await res.json();
if (body.status === 'done' || body.status === 'failed') {
clearInterval(timer);
await invalidateAll();
}
} catch {
// network blip — try again next tick
}
}, INTERVAL_MS);
return () => clearInterval(timer);
});
</script>
<svelte:head>
@@ -109,7 +137,7 @@
<div>
<p class="text-zinc-200 font-semibold text-lg">Scraping in progress…</p>
<p class="text-zinc-500 text-sm mt-1">
Fetching the first 20 chapters. Refresh the page in a minute.
Fetching the first 20 chapters. This page will refresh automatically.
</p>
{#if data.taskId}
<p class="text-zinc-600 text-xs mt-2 font-mono">task: {data.taskId}</p>

View File

@@ -2,60 +2,13 @@ import { error } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
import {
bookToListing,
type CatalogueResponse,
type NovelListing
} from '$lib/server/catalogue';
export interface NovelListing {
slug: string;
title: string;
cover: string;
rank: string;
rating: string;
chapters: string;
url: string;
// enriched fields
author?: string;
status?: string;
genres?: string[];
source_url?: string;
}
// Shape returned by GET /api/catalogue on the Go scraper.
interface CatalogueBook {
slug: string;
title: string;
author: string;
cover: string;
status: string;
genres: string[];
summary: string;
total_chapters: number;
source_url: string;
ranking: number;
rating: number;
}
interface CatalogueResponse {
books: CatalogueBook[];
page: number;
limit: number;
total: number;
has_next: boolean;
}
function bookToListing(book: CatalogueBook): NovelListing {
return {
slug: book.slug,
title: book.title,
cover: book.cover,
rank: book.ranking > 0 ? `#${book.ranking}` : '',
rating: book.rating > 0 ? String(book.rating) : '',
chapters: book.total_chapters > 0 ? `${book.total_chapters} chapters` : '',
url: book.source_url ?? '',
author: book.author,
status: book.status,
genres: book.genres ?? [],
source_url: book.source_url
};
}
export type { NovelListing };
export const load: PageServerLoad = async ({ url, locals }) => {
const page = url.searchParams.get('page') ?? '1';
@@ -72,20 +25,27 @@ export const load: PageServerLoad = async ({ url, locals }) => {
let novels: NovelListing[] = [];
let pageNum = parseInt(page, 10) || 1;
let hasNext = false;
let total = 0;
// Dynamic facets from Meilisearch — fall back to empty arrays if unavailable.
let genres: string[] = [];
let statuses: string[] = [];
try {
const res = await backendFetch(`/api/catalogue?${params.toString()}`);
if (!res.ok) {
log.error('browse', 'catalogue returned error', { status: res.status });
log.error('catalogue', 'catalogue returned error', { status: res.status });
throw error(502, `Catalogue fetch failed: ${res.status}`);
}
const data: CatalogueResponse = await res.json();
novels = (data.books ?? []).map(bookToListing);
pageNum = data.page ?? 1;
hasNext = data.has_next ?? false;
total = data.total ?? 0;
genres = data.facets?.genres ?? [];
statuses = data.facets?.statuses ?? [];
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
log.error('browse', 'catalogue network error', { err: String(e) });
log.error('catalogue', 'catalogue network error', { err: String(e) });
throw error(502, 'Could not reach catalogue service');
}
@@ -93,9 +53,14 @@ export const load: PageServerLoad = async ({ url, locals }) => {
novels,
page: pageNum,
hasNext,
total,
genre,
sort,
status,
// Dynamic filter options from Meilisearch facet distribution.
// Empty arrays when Meilisearch is unavailable — UI falls back to hardcoded lists.
genres,
statuses,
isAdmin: locals.user?.role === 'admin',
searchQuery: q.trim().length >= 2 ? q.trim() : '',
searchLocalCount: 0,

View File

@@ -54,7 +54,7 @@
sort: data.sort,
status: data.status
});
const res = await fetch(`/api/browse-page?${params.toString()}`);
const res = await fetch(`/api/catalogue-page?${params.toString()}`);
if (!res.ok) return;
const body: { novels: NovelListing[]; page: number; hasNext: boolean } = await res.json();
novels = [...novels, ...(body.novels ?? [])];
@@ -82,37 +82,35 @@
return () => observer.disconnect();
});
// Filter options
const genres = [
{ value: 'all', label: 'All Genres' },
{ value: 'action', label: 'Action' },
{ value: 'adventure', label: 'Adventure' },
{ value: 'comedy', label: 'Comedy' },
{ value: 'drama', label: 'Drama' },
{ value: 'fantasy', label: 'Fantasy' },
{ value: 'harem', label: 'Harem' },
{ value: 'historical', label: 'Historical' },
{ value: 'horror', label: 'Horror' },
{ value: 'isekai', label: 'Isekai' },
{ value: 'martial-arts', label: 'Martial Arts' },
{ value: 'mystery', label: 'Mystery' },
{ value: 'psychological', label: 'Psychological' },
{ value: 'romance', label: 'Romance' },
{ value: 'sci-fi', label: 'Sci-Fi' },
{ value: 'system', label: 'System' },
{ value: 'xianxia', label: 'Xianxia' }
// Filter options — built from Meilisearch facet distribution when available,
// with a hardcoded fallback list for when Meilisearch is not yet populated.
const FALLBACK_GENRES = [
'action', 'adventure', 'comedy', 'drama', 'fantasy', 'harem',
'historical', 'horror', 'isekai', 'martial-arts', 'mystery',
'psychological', 'romance', 'sci-fi', 'system', 'xianxia'
];
const genres = $derived([
{ value: 'all', label: 'All Genres' },
...((data.genres?.length ? data.genres : FALLBACK_GENRES).map((g: string) => ({
value: g,
label: g.charAt(0).toUpperCase() + g.slice(1).replace(/-/g, ' ')
})))
]);
const sorts = [
{ value: 'popular', label: 'Popular' },
{ value: 'new', label: 'New' },
{ value: 'update', label: 'Updated' },
{ value: 'top-rated', label: 'Top Rated' },
{ value: 'rank', label: 'Ranking' }
];
const statuses = [
const FALLBACK_STATUSES = ['ongoing', 'completed'];
const statuses = $derived([
{ value: 'all', label: 'All' },
{ value: 'ongoing', label: 'Ongoing' },
{ value: 'completed', label: 'Completed' }
];
...((data.statuses?.length ? data.statuses : FALLBACK_STATUSES).map((s: string) => ({
value: s,
label: s.charAt(0).toUpperCase() + s.slice(1)
})))
]);
// When sort=rank the ranking API is used — pagination + genre/status filters
// don't apply to that endpoint.
@@ -173,7 +171,7 @@
let filtersOpen = $state(false);
// Human-readable summary of active filters shown on the toggle button
const filterSummary = $derived(() => {
const filterSummary = $derived((() => {
const parts: string[] = [];
const sortLabel = sorts.find((s) => s.value === data.sort)?.label ?? data.sort;
parts.push(sortLabel);
@@ -186,7 +184,7 @@
parts.push(statusLabel);
}
return parts.join(' · ');
});
})());
// Whether any non-default filter is active (used to show a dot indicator)
const hasActiveFilters = $derived(
@@ -207,12 +205,12 @@
</script>
<svelte:head>
<title>Discover — libnovel</title>
<title>Catalogue — libnovel</title>
</svelte:head>
<!-- Header -->
<div class="mb-4">
<h1 class="text-2xl font-bold text-zinc-100">Discover</h1>
<h1 class="text-2xl font-bold text-zinc-100">Catalogue</h1>
<p class="text-zinc-400 text-sm mt-1">
{#if isSearchView}
{novels.length} result{novels.length !== 1 ? 's' : ''} for "<span class="text-zinc-200">{data.searchQuery}</span>"
@@ -251,7 +249,7 @@
<!-- Toolbar: search + filter toggle + view toggle + admin refresh -->
<div class="flex gap-2 mb-3">
<!-- Search (grows to fill available space) -->
<form method="GET" action="/browse" class="flex flex-1 gap-2 min-w-0">
<form method="GET" action="/catalogue" class="flex flex-1 gap-2 min-w-0">
<input
type="search"
name="q"
@@ -267,7 +265,7 @@
</button>
{#if data.searchQuery}
<a
href="/browse"
href="/catalogue"
class="px-3 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors whitespace-nowrap"
>
Clear
@@ -351,8 +349,8 @@
<!-- Active filter summary (shown when panel is closed and filters are active) -->
{#if !filtersOpen && hasActiveFilters}
<p class="text-xs text-zinc-500 mb-3">
<span class="text-zinc-400">{filterSummary()}</span>
<a href="/browse" class="ml-2 text-zinc-600 hover:text-zinc-400 underline underline-offset-2">clear</a>
<span class="text-zinc-400">{filterSummary}</span>
<a href="/catalogue" class="ml-2 text-zinc-600 hover:text-zinc-400 underline underline-offset-2">clear</a>
</p>
{/if}
@@ -382,7 +380,7 @@
</form>
{/if}
<form method="GET" action="/browse" class="mb-4 p-3 rounded-lg bg-zinc-800/60 border border-zinc-700 flex flex-col gap-3">
<form method="GET" action="/catalogue" class="mb-4 p-3 rounded-lg bg-zinc-800/60 border border-zinc-700 flex flex-col gap-3">
<input type="hidden" name="page" value="1" />
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
@@ -436,7 +434,7 @@
{/if}
<div class="flex gap-2 justify-end">
<a href="/browse" class="px-4 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors">
<a href="/catalogue" class="px-4 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors">
Reset
</a>
<button

View File

@@ -31,42 +31,18 @@
avatarUploading = true;
avatarError = '';
try {
// Step 1: get presigned PUT URL
const presignRes = await fetch('/api/profile/avatar', {
// POST raw bytes to the SvelteKit server, which proxies to MinIO internally.
const res = await fetch('/api/profile/avatar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mime_type: mimeType })
});
if (!presignRes.ok) {
const body = await presignRes.json().catch(() => ({})) as { message?: string };
avatarError = body.message ?? `Failed to prepare upload (${presignRes.status})`;
return;
}
const { upload_url, key } = await presignRes.json() as { upload_url: string; key: string };
// Step 2: PUT blob directly to MinIO
const putRes = await fetch(upload_url, {
method: 'PUT',
headers: { 'Content-Type': mimeType },
body: blob
});
if (!putRes.ok) {
avatarError = `Upload failed (${putRes.status})`;
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { message?: string };
avatarError = body.message ?? `Upload failed (${res.status})`;
return;
}
// Step 3: record key in PocketBase and get fresh presigned GET URL
const patchRes = await fetch('/api/profile/avatar', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key })
});
if (!patchRes.ok) {
const body = await patchRes.json().catch(() => ({})) as { message?: string };
avatarError = body.message ?? `Failed to save avatar (${patchRes.status})`;
return;
}
const result = await patchRes.json() as { avatar_url: string | null };
const result = await res.json() as { avatar_url: string | null };
avatarUrl = result.avatar_url;
} catch {
avatarError = 'Network error during upload';