chore: migrate to v3, Doppler secrets, clean up legacy code
Some checks failed
CI / v3 / Check ui (pull_request) Failing after 15s
CI / v3 / Test backend (pull_request) Failing after 16s
CI / v3 / Docker / backend (pull_request) Has been skipped
CI / v3 / Docker / runner (pull_request) Has been skipped
CI / v3 / Docker / ui (pull_request) Has been skipped

- Remove all pre-v3 code: scraper, ui-v2, backend v1, ios v1+v2, legacy CI workflows
- Flatten v3/ contents to repo root
- Add Doppler secrets management (project=libnovel, config=prd)
- Add justfile with doppler run wrappers for all docker compose commands
- Strip hardcoded env fallbacks from docker-compose.yml
- Add minimal README.md
- Clean up .gitignore
This commit is contained in:
Admin
2026-03-23 17:21:12 +05:00
parent 1118392811
commit 59e8cdb19a
522 changed files with 5259 additions and 80365 deletions

8
ui/src/app.d.ts vendored
View File

@@ -13,6 +13,14 @@ declare global {
// interface PageState {}
// interface Platform {}
}
// Umami analytics — injected by the script tag in +layout.svelte when
// PUBLIC_UMAMI_WEBSITE_ID is set. Optional so calls are no-ops in dev.
interface Window {
umami?: {
track(event: string, data?: Record<string, unknown>): void;
};
}
}
export {};

13
ui/src/hooks.client.ts Normal file
View File

@@ -0,0 +1,13 @@
import * as Sentry from '@sentry/sveltekit';
import { env } from '$env/dynamic/public';
// Sentry / GlitchTip client-side error tracking.
// No-op when PUBLIC_GLITCHTIP_DSN is unset (e.g. local dev).
if (env.PUBLIC_GLITCHTIP_DSN) {
Sentry.init({
dsn: env.PUBLIC_GLITCHTIP_DSN,
tracesSampleRate: 0.1
});
}
export const handleError = Sentry.handleErrorWithSentry();

View File

@@ -1,8 +1,47 @@
import type { Handle } from '@sveltejs/kit';
import { handleErrorWithSentry } from '@sentry/sveltekit';
import * as Sentry from '@sentry/sveltekit';
import { randomBytes, createHmac } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { env as pubEnv } from '$env/dynamic/public';
import { log } from '$lib/server/logger';
import { createUserSession, touchUserSession, isSessionRevoked } from '$lib/server/pocketbase';
import { drain as drainPresignCache } from '$lib/server/presignCache';
// ─── Sentry / GlitchTip server-side error tracking ────────────────────────────
// No-op when PUBLIC_GLITCHTIP_DSN is unset (e.g. local dev).
if (pubEnv.PUBLIC_GLITCHTIP_DSN) {
Sentry.init({
dsn: pubEnv.PUBLIC_GLITCHTIP_DSN,
tracesSampleRate: 0.1
});
}
export const handleError = handleErrorWithSentry();
// ─── Graceful shutdown ────────────────────────────────────────────────────────
//
// When Docker/Kubernetes sends SIGTERM (or the user sends SIGINT), we:
// 1. Set shuttingDown = true so new requests immediately receive 503.
// 2. Flush/drain in-process caches (presign URL cache).
// 3. Allow Node.js to exit naturally once in-flight requests finish.
//
// adapter-node does not provide a built-in hook for this, so we wire it here
// in hooks.server.ts which runs in the server Node.js process.
let shuttingDown = false;
function shutdown(signal: string) {
if (shuttingDown) return;
shuttingDown = true;
log.info('shutdown', `received ${signal}, draining in-flight requests`);
drainPresignCache();
// Don't call process.exit() — let Node exit naturally once the event loop
// is empty (adapter-node closes the HTTP server on its own).
}
process.once('SIGTERM', () => shutdown('SIGTERM'));
process.once('SIGINT', () => shutdown('SIGINT'));
const SESSION_COOKIE = 'libnovel_session';
const AUTH_COOKIE = 'libnovel_auth';
@@ -70,6 +109,12 @@ export function parseAuthToken(token: string): { id: string; username: string; r
// ─── Hook ─────────────────────────────────────────────────────────────────────
export const handle: Handle = async ({ event, resolve }) => {
// During graceful shutdown, reject new requests immediately so the load
// balancer / Docker health-check can drain existing connections.
if (shuttingDown) {
return new Response('Service shutting down', { status: 503 });
}
// Anonymous session cookie (for reading progress)
let sessionId = event.cookies.get(SESSION_COOKIE) ?? '';
if (!sessionId) {

View File

@@ -13,7 +13,7 @@
* nextChapterPrefetched === chapter), use the cached URL immediately.
* 3. Otherwise try GET /api/presign/audio — if 200, set audioUrl → layout plays.
* 4. If 404, POST /api/audio/:slug/:n to generate. Drive pseudo progress bar.
* On success, presign again and set audioUrl.
* On success (200 or 202→done), presign and set audioUrl directly from MinIO.
*
* ── Voice selection ──────────────────────────────────────────────────────
* A "Change voice" panel lets users pick from the available Kokoro voices.
@@ -49,6 +49,8 @@
*/
import { audioStore } from '$lib/audio.svelte';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
interface Props {
slug: string;
@@ -325,14 +327,16 @@
voice: targetVoice
});
const res = await fetch(`/api/presign/audio?${params}`);
if (res.status === 404) return null;
// 202: TTS was just enqueued by the presign endpoint — audio not ready yet.
// 404: legacy fallback (should no longer occur after endpoint change).
if (res.status === 202 || res.status === 404) return null;
if (!res.ok) throw new Error(`presign HTTP ${res.status}`);
const data = (await res.json()) as { url: string };
return data.url;
}
type AudioStatusResponse =
| { status: 'done'; url: string; filename: string }
| { status: 'done' }
| { status: 'pending' | 'generating'; job_id: string }
| { status: 'idle' }
| { status: 'failed'; error?: string };
@@ -407,29 +411,31 @@
});
if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`);
// If the scraper returned cached audio immediately (200), use the url.
// Whether the server returned 200 (already cached) or 202 (enqueued),
// always presign — the status endpoint no longer returns a proxy URL.
if (res.status === 200) {
const cached = (await res.json()) as { url: string };
// Body is { status: 'done' } — audio confirmed in MinIO. Presign it.
await res.body?.cancel();
}
// else 202: generation enqueued — fall through to poll.
if (res.status !== 200) {
// 202: poll until done.
const final = await pollAudioStatus(slug, nextChapter, voice);
stopNextProgress();
audioStore.nextProgress = 100;
if (final.status === 'failed') {
throw new Error(`Prefetch failed: ${(final as { error?: string }).error ?? 'unknown'}`);
}
} else {
stopNextProgress();
audioStore.nextProgress = 100;
audioStore.nextAudioUrl = cached.url;
audioStore.nextStatus = 'prefetched';
return;
}
// 202: poll until done.
const final = await pollAudioStatus(slug, nextChapter, voice);
stopNextProgress();
audioStore.nextProgress = 100;
if (final.status === 'failed') {
throw new Error(`Prefetch failed: ${(final as { error?: string }).error ?? 'unknown'}`);
}
// Use the URL from the status response, or fall back to presign.
const doneUrl =
(final as { url?: string }).url ?? (await tryPresign(slug, nextChapter, voice));
if (!doneUrl) throw new Error('Prefetch: audio done but no URL available');
// Audio is ready in MinIO — get a direct presigned URL.
const doneUrl = await tryPresign(slug, nextChapter, voice);
if (!doneUrl) throw new Error('Prefetch: audio done but presign returned 404');
audioStore.nextAudioUrl = doneUrl;
audioStore.nextStatus = 'prefetched';
@@ -521,31 +527,25 @@
});
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
// If the scraper returned cached audio immediately (200), use the url.
if (res.status === 200) {
const cached = (await res.json()) as { url: string };
await finishProgress();
audioStore.audioUrl = cached.url;
audioStore.status = 'ready';
maybeStartPrefetch();
return;
}
if (res.status !== 200) {
// 202: generation enqueued — poll until done.
const final = await pollAudioStatus(slug, chapter, voice);
// 202: poll until done.
const final = await pollAudioStatus(slug, chapter, voice);
if (final.status === 'failed') {
throw new Error(
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
);
if (final.status === 'failed') {
throw new Error(
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
);
}
} else {
// 200: already cached — body is { status: 'done' }, no url needed.
await res.body?.cancel();
}
await finishProgress();
// Use the URL from the status response, or fall back to presign.
const doneUrl =
(final as { url?: string }).url ?? (await tryPresign(slug, chapter, voice));
if (!doneUrl) throw new Error('Audio generated but no URL available');
// Audio is ready in MinIO — always use a presigned URL for direct playback.
const doneUrl = await tryPresign(slug, chapter, voice);
if (!doneUrl) throw new Error('Audio generated but presign returned 404');
audioStore.audioUrl = doneUrl;
audioStore.status = 'ready';
// Don't restore time for freshly generated audio — position is 0
@@ -610,6 +610,11 @@
// Not yet loaded — start the full flow.
await startPlayback();
// Track audio play after successful load.
if (audioStore.status === 'ready') {
window.umami?.track('audio_played', { slug, chapter });
}
}
function formatTime(s: number): string {
@@ -633,21 +638,21 @@
<!-- Voice selector button -->
{#if voices.length > 0}
<button
<Button
variant="ghost"
size="sm"
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
class="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors {showVoicePanel
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
: 'text-zinc-400 bg-zinc-700 hover:bg-zinc-600 hover:text-zinc-200'}"
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : '')}
title="Change voice"
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
<svg class="w-3 h-3 flex-shrink-0 transition-transform {showVoicePanel ? 'rotate-180' : ''}" fill="currentColor" viewBox="0 0 24 24">
<svg class={cn('w-3 h-3 flex-shrink-0 transition-transform', showVoicePanel && 'rotate-180')} fill="currentColor" viewBox="0 0 24 24">
<path d="M7 10l5 5 5-5z"/>
</svg>
</button>
</Button>
{/if}
</div>
@@ -656,20 +661,22 @@
<div class="mb-3 rounded-lg border border-zinc-600 bg-zinc-900 overflow-hidden">
<div class="px-3 py-2 border-b border-zinc-700 flex items-center justify-between">
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Choose Voice</span>
<button
<Button
variant="ghost"
size="icon"
class="h-6 w-6 text-zinc-500 hover:text-zinc-300"
onclick={() => { stopSample(); showVoicePanel = false; }}
class="text-zinc-500 hover:text-zinc-300 transition-colors"
aria-label="Close voice selector"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</Button>
</div>
<div class="max-h-64 overflow-y-auto">
{#each voices as v (v)}
<div
class="flex items-center gap-2 px-3 py-2 hover:bg-zinc-800 transition-colors cursor-pointer {audioStore.voice === v ? 'bg-amber-400/10' : ''}"
class={cn('flex items-center gap-2 px-3 py-2 hover:bg-zinc-800 transition-colors cursor-pointer', audioStore.voice === v && 'bg-amber-400/10')}
role="button"
tabindex="0"
onclick={() => selectVoice(v)}
@@ -685,32 +692,30 @@
</div>
<!-- Voice name -->
<span class="flex-1 text-xs {audioStore.voice === v ? 'text-amber-400 font-medium' : 'text-zinc-300'}">
<span class={cn('flex-1 text-xs', audioStore.voice === v ? 'text-amber-400 font-medium' : 'text-zinc-300')}>
{voiceLabel(v)}
</span>
<span class="text-zinc-600 text-xs font-mono">{v}</span>
<!-- Sample play button (stop propagation so click doesn't select) -->
<button
<Button
variant="ghost"
size="icon"
class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : 'text-zinc-500 hover:text-zinc-200')}
onclick={(e) => { e.stopPropagation(); playSample(v); }}
class="p-1 rounded transition-colors flex-shrink-0 {samplePlayingVoice === v
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
: 'text-zinc-500 hover:text-zinc-200 hover:bg-zinc-700'}"
title={samplePlayingVoice === v ? 'Stop sample' : 'Play sample'}
aria-label={samplePlayingVoice === v ? `Stop ${v} sample` : `Play ${v} sample`}
>
{#if samplePlayingVoice === v}
<!-- Stop icon -->
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 6h12v12H6z"/>
</svg>
{:else}
<!-- Play icon -->
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
</Button>
</div>
{/each}
</div>
@@ -736,31 +741,24 @@
<!-- ── This chapter is the active one ── -->
{#if audioStore.status === 'idle' || audioStore.status === 'error'}
<!-- Should not normally reach here while current, but handle gracefully -->
{#if audioStore.status === 'error'}
<p class="text-red-400 text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
{/if}
<button
onclick={handlePlay}
class="px-4 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors flex items-center gap-2"
>
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<Button variant="default" size="sm" onclick={handlePlay}>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Play narration
</button>
</Button>
{:else if audioStore.status === 'loading'}
<button
disabled
class="px-4 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold opacity-50 cursor-not-allowed flex items-center gap-2"
>
<Button variant="default" size="sm" disabled>
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Loading…
</button>
</Button>
{:else if audioStore.status === 'generating'}
<div class="space-y-2">
@@ -774,68 +772,68 @@
<p class="text-xs text-zinc-500 tabular-nums">{Math.round(audioStore.progress)}%</p>
</div>
{:else if audioStore.status === 'ready'}
<!-- Mini-bar is the canonical control surface — show a compact indicator here -->
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-xs text-zinc-400">
{#if audioStore.isPlaying}
<svg class="w-3.5 h-3.5 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
<span>Playing — controls below</span>
{:else}
<svg class="w-3.5 h-3.5 flex-shrink-0 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<span>Paused — controls below</span>
{:else if audioStore.status === 'ready'}
<!-- Mini-bar is the canonical control surface — show a compact indicator here -->
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-xs text-zinc-400">
{#if audioStore.isPlaying}
<svg class="w-3.5 h-3.5 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
<span>Playing — controls below</span>
{:else}
<svg class="w-3.5 h-3.5 flex-shrink-0 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<span>Paused — controls below</span>
{/if}
<span class="tabular-nums text-zinc-500">
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
</span>
</div>
<!-- Auto-next toggle (keep here as useful context) -->
{#if nextChapter !== null && nextChapter !== undefined}
<Button
variant="ghost"
size="sm"
class={cn('gap-1.5 text-xs flex-shrink-0', audioStore.autoNext ? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25' : 'text-zinc-500')}
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
title={audioStore.autoNext ? `Auto-next on — will play Ch.${nextChapter} automatically` : 'Auto-next off'}
aria-pressed={audioStore.autoNext}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg>
Auto
</Button>
{/if}
<span class="tabular-nums text-zinc-500">
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
</span>
</div>
<!-- Auto-next toggle (keep here as useful context) -->
{#if nextChapter !== null && nextChapter !== undefined}
<button
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
class="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors flex-shrink-0 {audioStore.autoNext
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
: 'text-zinc-500 bg-zinc-700 hover:bg-zinc-600 hover:text-zinc-200'}"
title={audioStore.autoNext ? `Auto-next on — will play Ch.${nextChapter} automatically` : 'Auto-next off'}
aria-pressed={audioStore.autoNext}
>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg>
Auto
</button>
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
{#if audioStore.autoNext && nextChapter !== null && nextChapter !== undefined}
<div class="mt-2">
{#if audioStore.nextStatus === 'prefetching'}
<div class="flex items-center gap-2 text-xs text-zinc-500">
<svg class="w-3 h-3 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>Preparing Ch.{nextChapter}{Math.round(audioStore.nextProgress)}%</span>
</div>
{:else if audioStore.nextStatus === 'prefetched'}
<p class="text-xs text-zinc-500 flex items-center gap-1">
<svg class="w-3 h-3 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
Ch.{nextChapter} ready
</p>
{:else if audioStore.nextStatus === 'failed'}
<p class="text-xs text-zinc-600">Ch.{nextChapter} will generate on navigate</p>
{/if}
</div>
{/if}
</div>
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
{#if audioStore.autoNext && nextChapter !== null && nextChapter !== undefined}
<div class="mt-2">
{#if audioStore.nextStatus === 'prefetching'}
<div class="flex items-center gap-2 text-xs text-zinc-500">
<svg class="w-3 h-3 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<span>Preparing Ch.{nextChapter}{Math.round(audioStore.nextProgress)}%</span>
</div>
{:else if audioStore.nextStatus === 'prefetched'}
<p class="text-xs text-zinc-500 flex items-center gap-1">
<svg class="w-3 h-3 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
</svg>
Ch.{nextChapter} ready
</p>
{:else if audioStore.nextStatus === 'failed'}
<p class="text-xs text-zinc-600">Ch.{nextChapter} will generate on navigate</p>
{/if}
</div>
{/if}
{/if}
{:else if audioStore.active}
<!-- ── A different chapter is currently playing ── -->
@@ -843,24 +841,18 @@
<p class="text-xs text-zinc-400">
Now playing: {audioStore.chapterTitle || `Ch.${audioStore.chapter}`}
</p>
<button
onclick={startPlayback}
class="px-3 py-1 rounded bg-zinc-700 text-zinc-200 text-xs font-medium hover:bg-zinc-600 transition-colors flex-shrink-0"
>
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
Load this chapter
</button>
</Button>
</div>
{:else}
<!-- ── Idle — nothing playing ── -->
<button
onclick={handlePlay}
class="px-4 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors flex items-center gap-2"
>
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<Button variant="default" size="sm" onclick={handlePlay}>
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
Play narration
</button>
</Button>
{/if}
</div>

View File

@@ -1,7 +1,10 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import Cropper from 'cropperjs';
import type { default as CropperType } from 'cropperjs';
import 'cropperjs/dist/cropper.css';
import { Dialog, DialogHeader, DialogTitle, DialogFooter } from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
interface Props {
file: File;
@@ -12,8 +15,9 @@
let { file, onconfirm, oncancel }: Props = $props();
let imgEl: HTMLImageElement | undefined = $state();
let cropper: Cropper | null = null;
let cropper: CropperType | null = null;
let objectUrl = '';
let open = $state(true);
// Initialize cropper once the img element is bound and the file is known.
// Use a $effect so it runs after the DOM is ready (replaces onMount).
@@ -73,21 +77,22 @@
0.9
);
}
function handleClose() {
open = false;
oncancel();
}
</script>
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
aria-label="Crop profile picture"
>
<div class="bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl w-full max-w-sm flex flex-col gap-4 p-5">
<h2 class="text-base font-semibold text-zinc-100">Crop profile picture</h2>
<Dialog bind:open onclose={handleClose} class="max-w-sm">
<DialogHeader>
<DialogTitle>Crop profile picture</DialogTitle>
</DialogHeader>
<!-- Cropper image container — overflow must be visible so cropperjs can
render the crop canvas outside the natural image bounds. The fixed
height gives cropperjs a stable container to size itself against. -->
<!-- Cropper image container — overflow must be visible so cropperjs can
render the crop canvas outside the natural image bounds. The fixed
height gives cropperjs a stable container to size itself against. -->
<div class="px-5">
<div class="rounded-xl bg-zinc-800" style="height: 300px; position: relative;">
<img
bind:this={imgEl}
@@ -95,22 +100,13 @@
style="display:block; max-width:100%; max-height:100%;"
/>
</div>
<p class="text-xs text-zinc-500 text-center">Drag to reposition · pinch or scroll to zoom · drag corners to resize</p>
<div class="flex gap-3">
<button
onclick={oncancel}
class="flex-1 py-2 rounded-lg border border-zinc-600 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors"
>
Cancel
</button>
<button
onclick={confirm}
class="flex-1 py-2 rounded-lg bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
>
Use photo
</button>
</div>
<p class="text-xs text-zinc-500 text-center mt-3">
Drag to reposition · pinch or scroll to zoom · drag corners to resize
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={handleClose}>Cancel</Button>
<Button variant="default" onclick={confirm}>Use photo</Button>
</DialogFooter>
</Dialog>

View File

@@ -1,17 +1,8 @@
<script lang="ts">
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 { Button } from '$lib/components/ui/button';
import { Textarea } from '$lib/components/ui/textarea';
import { cn } from '$lib/utils';
import type { BookComment } from '$lib/types';
let {
slug,
isLoggedIn = false,
@@ -259,63 +250,56 @@
{/if}
</h2>
<!-- Sort tabs -->
{#if !loading && comments.length > 0}
<div class="flex items-center gap-1 text-xs rounded-lg bg-zinc-800/60 p-1">
<button
onclick={() => (sort = 'top')}
class="px-2.5 py-1 rounded-md transition-colors {sort === 'top'
? 'bg-zinc-700 text-zinc-100'
: 'text-zinc-500 hover:text-zinc-300'}"
>
Top
</button>
<button
onclick={() => (sort = 'new')}
class="px-2.5 py-1 rounded-md transition-colors {sort === 'new'
? 'bg-zinc-700 text-zinc-100'
: 'text-zinc-500 hover:text-zinc-300'}"
>
New
</button>
</div>
{/if}
<!-- Sort tabs -->
{#if !loading && comments.length > 0}
<div class="flex items-center gap-1 text-xs rounded-lg bg-zinc-800/60 p-1">
<Button
variant="ghost"
size="sm"
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'top' ? 'bg-zinc-700 text-zinc-100 hover:bg-zinc-700' : 'text-zinc-500 hover:text-zinc-300')}
onclick={() => (sort = 'top')}
>Top</Button>
<Button
variant="ghost"
size="sm"
class={cn('px-2.5 py-1 h-auto text-xs rounded-md', sort === 'new' ? 'bg-zinc-700 text-zinc-100 hover:bg-zinc-700' : 'text-zinc-500 hover:text-zinc-300')}
onclick={() => (sort = 'new')}
>New</Button>
</div>
{/if}
</div>
<!-- Post form -->
<div class="mb-6">
{#if isLoggedIn}
<div class="flex flex-col gap-2">
<textarea
<Textarea
bind:value={newBody}
placeholder="Write a comment…"
rows="3"
class="w-full px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm placeholder-zinc-500 resize-none focus:outline-none focus:border-amber-400 transition-colors"
></textarea>
rows={3}
/>
<div class="flex items-center justify-between gap-3">
<span class="text-xs {charOver ? 'text-red-400' : 'text-zinc-600'} tabular-nums">
<span class={cn('text-xs tabular-nums', charOver ? 'text-red-400' : 'text-zinc-600')}>
{charCount}/2000
</span>
<div class="flex items-center gap-3">
{#if postError}
<span class="text-xs text-red-400">{postError}</span>
{/if}
<button
onclick={postComment}
<Button
variant="default"
size="sm"
disabled={posting || !newBody.trim() || charOver}
class="px-4 py-1.5 rounded-lg text-sm font-medium transition-colors
{posting || !newBody.trim() || charOver
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
onclick={postComment}
>
{posting ? 'Posting…' : 'Post'}
</button>
</Button>
</div>
</div>
</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}
@@ -366,115 +350,115 @@
<!-- Body -->
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{comment.body}</p>
<!-- Actions row: votes + reply + delete -->
<div class="flex items-center gap-3 pt-1 flex-wrap">
<!-- Upvote -->
<button
onclick={() => vote(comment.id, 'up')}
disabled={voting}
title="Upvote"
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
{myVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}"
<!-- Actions row: votes + reply + delete -->
<div class="flex items-center gap-3 pt-1 flex-wrap">
<!-- Upvote -->
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
disabled={voting}
onclick={() => vote(comment.id, 'up')}
title="Upvote"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
<span class="tabular-nums">{comment.upvotes ?? 0}</span>
</Button>
<!-- Downvote -->
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', myVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300')}
disabled={voting}
onclick={() => vote(comment.id, 'down')}
title="Downvote"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
</Button>
<!-- Reply button -->
{#if isLoggedIn}
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyingTo === comment.id ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
onclick={() => {
if (replyingTo === comment.id) {
replyingTo = null;
replyBody = '';
replyError = '';
} else {
replyingTo = comment.id;
replyBody = '';
replyError = '';
}
}}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
<span class="tabular-nums">{comment.upvotes ?? 0}</span>
</button>
Reply
</Button>
{/if}
<!-- Downvote -->
<button
onclick={() => vote(comment.id, 'down')}
disabled={voting}
title="Downvote"
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
{myVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300'}"
<!-- Delete (owner only) -->
{#if isOwner}
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-zinc-600 hover:text-red-400 ml-auto"
disabled={deleting}
onclick={() => deleteComment(comment.id)}
title="Delete comment"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
<span class="tabular-nums">{comment.downvotes ?? 0}</span>
</button>
Delete
</Button>
{/if}
</div>
<!-- Reply button -->
{#if isLoggedIn}
<button
onclick={() => {
if (replyingTo === comment.id) {
replyingTo = null;
replyBody = '';
replyError = '';
} else {
replyingTo = comment.id;
replyBody = '';
replyError = '';
}
}}
class="flex items-center gap-1 text-xs transition-colors
{replyingTo === comment.id
? 'text-amber-400'
: 'text-zinc-500 hover:text-zinc-300'}"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
Reply
</button>
{/if}
<!-- Delete (owner only) -->
{#if isOwner}
<button
onclick={() => deleteComment(comment.id)}
disabled={deleting}
class="flex items-center gap-1 text-xs text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50 ml-auto"
title="Delete comment"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
Delete
</button>
{/if}
</div>
<!-- Inline reply form -->
{#if replyingTo === comment.id}
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-zinc-700">
<textarea
bind:value={replyBody}
placeholder="Write a reply…"
rows="2"
class="w-full px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm placeholder-zinc-500 resize-none focus:outline-none focus:border-amber-400 transition-colors"
></textarea>
<div class="flex items-center justify-between gap-2">
<span class="text-xs {replyCharOver ? 'text-red-400' : 'text-zinc-600'} tabular-nums">
{replyCharCount}/2000
</span>
<div class="flex items-center gap-2">
{#if replyError}
<span class="text-xs text-red-400">{replyError}</span>
{/if}
<button
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
class="px-3 py-1 rounded-lg text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
>
Cancel
</button>
<button
onclick={() => postReply(comment.id)}
disabled={replyPosting || !replyBody.trim() || replyCharOver}
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors
{replyPosting || !replyBody.trim() || replyCharOver
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
>
{replyPosting ? 'Posting…' : 'Reply'}
</button>
</div>
<!-- Inline reply form -->
{#if replyingTo === comment.id}
<div class="mt-1 flex flex-col gap-2 pl-2 border-l-2 border-zinc-700">
<Textarea
bind:value={replyBody}
placeholder="Write a reply…"
rows={2}
/>
<div class="flex items-center justify-between gap-2">
<span class={cn('text-xs tabular-nums', replyCharOver ? 'text-red-400' : 'text-zinc-600')}>
{replyCharCount}/2000
</span>
<div class="flex items-center gap-2">
{#if replyError}
<span class="text-xs text-red-400">{replyError}</span>
{/if}
<Button
variant="ghost"
size="sm"
class="text-zinc-400 hover:text-zinc-200"
onclick={() => { replyingTo = null; replyBody = ''; replyError = ''; }}
>Cancel</Button>
<Button
variant="default"
size="sm"
disabled={replyPosting || !replyBody.trim() || replyCharOver}
onclick={() => postReply(comment.id)}
>
{replyPosting ? 'Posting…' : 'Reply'}
</Button>
</div>
</div>
{/if}
</div>
{/if}
<!-- Replies -->
{#if comment.replies && comment.replies.length > 0}
@@ -507,48 +491,52 @@
<!-- Reply body -->
<p class="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap break-words">{reply.body}</p>
<!-- Reply actions -->
<div class="flex items-center gap-3 pt-0.5">
<button
onclick={() => vote(reply.id, 'up', comment.id)}
disabled={replyVoting}
title="Upvote"
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
{replyVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300'}"
<!-- Reply actions -->
<div class="flex items-center gap-3 pt-0.5">
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'up' ? 'text-amber-400' : 'text-zinc-500 hover:text-zinc-300')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'up', comment.id)}
title="Upvote"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
</svg>
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
</Button>
<Button
variant="ghost"
size="sm"
class={cn('h-auto px-1 py-0 gap-1 text-xs', replyVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300')}
disabled={replyVoting}
onclick={() => vote(reply.id, 'down', comment.id)}
title="Downvote"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
<span class="tabular-nums">{reply.downvotes ?? 0}</span>
</Button>
{#if replyIsOwner}
<Button
variant="ghost"
size="sm"
class="h-auto px-1 py-0 gap-1 text-xs text-zinc-600 hover:text-red-400 ml-auto"
disabled={replyDeleting}
onclick={() => deleteComment(reply.id, comment.id)}
title="Delete reply"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
<span class="tabular-nums">{reply.upvotes ?? 0}</span>
</button>
<button
onclick={() => vote(reply.id, 'down', comment.id)}
disabled={replyVoting}
title="Downvote"
class="flex items-center gap-1 text-xs transition-colors disabled:opacity-50
{replyVote === 'down' ? 'text-red-400' : 'text-zinc-500 hover:text-zinc-300'}"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 14H5.236a2 2 0 01-1.789-2.894l3.5-7A2 2 0 018.736 3h4.018a2 2 0 01.485.06l3.76.94m-7 10v5a2 2 0 002 2h.096c.5 0 .905-.405.905-.904 0-.715.211-1.413.608-2.008L17 13V4m-7 10h2m5-10h2a2 2 0 012 2v6a2 2 0 01-2 2h-2.5"/>
</svg>
<span class="tabular-nums">{reply.downvotes ?? 0}</span>
</button>
{#if replyIsOwner}
<button
onclick={() => deleteComment(reply.id, comment.id)}
disabled={replyDeleting}
class="flex items-center gap-1 text-xs text-zinc-600 hover:text-red-400 transition-colors disabled:opacity-50 ml-auto"
title="Delete reply"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
Delete
</button>
{/if}
</div>
Delete
</Button>
{/if}
</div>
</div>
{/each}
</div>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
type Variant = 'default' | 'secondary' | 'outline' | 'destructive';
interface Props {
variant?: Variant;
class?: string;
children?: Snippet;
}
let { variant = 'default', class: className = '', children }: Props = $props();
const base =
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none';
const variants: Record<Variant, string> = {
default: 'border-transparent bg-amber-400 text-zinc-900',
secondary: 'border-transparent bg-zinc-700 text-zinc-200',
outline: 'border-zinc-600 text-zinc-300',
destructive: 'border-transparent bg-red-500/20 text-red-400',
};
</script>
<span class={cn(base, variants[variant], className)}>
{@render children?.()}
</span>

View File

@@ -0,0 +1 @@
export { default as Badge } from './Badge.svelte';

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
type Variant = 'default' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link';
type Size = 'default' | 'sm' | 'lg' | 'icon';
interface Props {
variant?: Variant;
size?: Size;
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
class?: string;
onclick?: (e: MouseEvent) => void;
children?: Snippet;
[key: string]: unknown;
}
let {
variant = 'default',
size = 'default',
disabled = false,
type = 'button',
class: className = '',
onclick,
children,
...rest
}: Props = $props();
const base =
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900 disabled:pointer-events-none disabled:opacity-50';
const variants: Record<Variant, string> = {
default: 'bg-amber-400 text-zinc-900 hover:bg-amber-300',
secondary: 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600',
outline: 'border border-zinc-600 bg-transparent text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100',
ghost: 'bg-transparent text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100',
destructive: 'bg-red-500/20 text-red-400 hover:bg-red-500/30 hover:text-red-300',
link: 'text-amber-400 underline-offset-4 hover:underline bg-transparent p-0 h-auto',
};
const sizes: Record<Size, string> = {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-11 rounded-md px-8',
icon: 'h-9 w-9',
};
</script>
<button
{type}
{disabled}
class={cn(base, variants[variant], sizes[size], className)}
{onclick}
{...rest}
>
{@render children?.()}
</button>

View File

@@ -0,0 +1 @@
export { default as Button } from './Button.svelte';

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class={cn('rounded-xl border border-zinc-700 bg-zinc-800/50', className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class={cn('p-5 pt-0', className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<p class={cn('text-sm text-zinc-400', className)}>
{@render children?.()}
</p>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class={cn('flex items-center p-5 pt-0', className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class={cn('flex flex-col space-y-1.5 p-5', className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<h3 class={cn('font-semibold leading-none tracking-tight text-zinc-100', className)}>
{@render children?.()}
</h3>

View File

@@ -0,0 +1,6 @@
export { default as Card } from './Card.svelte';
export { default as CardHeader } from './CardHeader.svelte';
export { default as CardTitle } from './CardTitle.svelte';
export { default as CardDescription } from './CardDescription.svelte';
export { default as CardContent } from './CardContent.svelte';
export { default as CardFooter } from './CardFooter.svelte';

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
open?: boolean;
onclose?: () => void;
class?: string;
children?: Snippet;
}
let { open = $bindable(false), onclose, class: className = '', children }: Props = $props();
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
open = false;
onclose?.();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
open = false;
onclose?.();
}
}
</script>
<svelte:window onkeydown={handleKeyDown} />
{#if open}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
role="dialog"
aria-modal="true"
onclick={handleBackdropClick}
>
<div class={cn('bg-zinc-900 rounded-2xl border border-zinc-700 shadow-2xl w-full max-w-sm', className)}>
{@render children?.()}
</div>
</div>
{/if}

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class={cn('flex flex-col gap-4 p-5', className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class={cn('flex items-center justify-end gap-2 px-5 pb-5', className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class={cn('flex flex-col space-y-1.5 px-5 pt-5', className)}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<h2 class={cn('text-base font-semibold leading-none tracking-tight text-zinc-100', className)}>
{@render children?.()}
</h2>

View File

@@ -0,0 +1,5 @@
export { default as Dialog } from './Dialog.svelte';
export { default as DialogContent } from './DialogContent.svelte';
export { default as DialogHeader } from './DialogHeader.svelte';
export { default as DialogTitle } from './DialogTitle.svelte';
export { default as DialogFooter } from './DialogFooter.svelte';

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from '$lib/utils';
interface Props {
class?: string;
orientation?: 'horizontal' | 'vertical';
}
let { class: className = '', orientation = 'horizontal' }: Props = $props();
</script>
<div
role="separator"
class={cn(
'shrink-0 bg-zinc-700',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className
)}
></div>

View File

@@ -0,0 +1 @@
export { default as Separator } from './Separator.svelte';

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { cn } from '$lib/utils';
interface Props {
value?: string;
placeholder?: string;
rows?: number;
disabled?: boolean;
class?: string;
onchange?: (e: Event) => void;
oninput?: (e: Event) => void;
[key: string]: unknown;
}
let {
value = $bindable(''),
placeholder = '',
rows = 3,
disabled = false,
class: className = '',
onchange,
oninput,
...rest
}: Props = $props();
</script>
<textarea
bind:value
{placeholder}
{rows}
{disabled}
class={cn(
'flex w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 resize-none transition-colors',
'focus:outline-none focus:border-amber-400',
'disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{onchange}
{oninput}
{...rest}
></textarea>

View File

@@ -0,0 +1 @@
export { default as Textarea } from './Textarea.svelte';

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

@@ -1,16 +1,15 @@
/**
* Server-side MinIO presign helper.
* Calls the scraper API to get presigned URLs, then optionally rewrites
* Calls the backend API to get presigned URLs, then optionally rewrites
* the MinIO host to the public-facing URL for browser use.
*
* Never import this from client-side code.
*/
import { env } from '$env/dynamic/private';
import { env as pubEnv } from '$env/dynamic/public';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
// Public MinIO URL — used to rewrite presigned URLs so the browser can reach MinIO directly.
// In docker-compose this would differ from the internal endpoint.
const MINIO_PUBLIC_URL = pubEnv.PUBLIC_MINIO_PUBLIC_URL ?? 'http://localhost:9000';
@@ -27,11 +26,11 @@ function extFromMime(mime: string): string {
/**
* Returns a short-lived presigned PUT URL for uploading an avatar directly to MinIO,
* along with the object key to record in PocketBase after upload completes.
* Routed through the Go scraper which holds MinIO credentials.
* Routed through the Go backend which holds MinIO credentials.
*/
export async function presignAvatarUploadUrl(userId: string, mimeType: string): Promise<{ uploadUrl: string; key: string }> {
const ext = extFromMime(mimeType);
const res = await fetch(`${SCRAPER_URL}/api/presign/avatar-upload/${encodeURIComponent(userId)}?ext=${ext}`);
const res = await backendFetch(`/api/presign/avatar-upload/${encodeURIComponent(userId)}?ext=${ext}`);
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`presign avatar upload failed: ${res.status} ${body}`);
@@ -45,26 +44,43 @@ export async function presignAvatarUploadUrl(userId: string, mimeType: string):
* Returns null if no avatar exists.
*/
export async function presignAvatarUrl(userId: string): Promise<string | null> {
const res = await fetch(`${SCRAPER_URL}/api/presign/avatar/${encodeURIComponent(userId)}`);
const res = await backendFetch(`/api/presign/avatar/${encodeURIComponent(userId)}`);
if (res.status === 404) return null;
if (!res.ok) {
const body = await res.text().catch(() => '');
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;
}
/**
* Rewrites the MinIO host in a presigned URL to the public-facing URL.
* The presigned URL is signed against the internal endpoint (e.g. minio:9000),
* but the browser needs the public URL (e.g. localhost:9000 in dev, or a CDN in prod).
* Rewriting the host preserves all query params (signature, expiry, etc).
*
* The Go backend presigns URLs against its internal endpoint (e.g. minio:9000)
* when PUBLIC_MINIO_PUBLIC_URL is not set or equals the internal endpoint.
* In that case the browser must reach MinIO via the public URL (e.g.
* localhost:9000 in dev), so we swap the origin.
*
* NOTE: AWS Signature V4 DOES include the Host header in the canonical request
* (via X-Amz-SignedHeaders=host). Rewriting the host here would break the
* signature. This function is therefore only a no-op safety net — in
* production the Go backend is configured with MINIO_PUBLIC_ENDPOINT equal to
* the externally-reachable hostname, so presigned URLs already carry the right
* host and no rewrite is needed.
*
* For local dev: MINIO_PUBLIC_ENDPOINT=http://localhost:9000 and the backend
* presigns with localhost:9000 (the public client), so this rewrite is again
* a no-op (origins already match).
*/
function rewriteHost(presignedUrl: string): string {
try {
const u = new URL(presignedUrl);
const pub = new URL(MINIO_PUBLIC_URL);
// No-op if already pointing at the right origin.
if (u.protocol === pub.protocol && u.hostname === pub.hostname && u.port === pub.port) {
return presignedUrl;
}
u.protocol = pub.protocol;
u.hostname = pub.hostname;
u.port = pub.port;
@@ -86,7 +102,7 @@ export async function presignChapter(slug: string, n: number, rewrite = false):
log.debug('minio', 'presigning chapter', { slug, n });
let res: Response;
try {
res = await fetch(`${SCRAPER_URL}/api/presign/chapter/${slug}/${n}`);
res = await backendFetch(`/api/presign/chapter/${slug}/${n}`);
} catch (e) {
log.error('minio', 'presign chapter network error', { slug, n, err: String(e) });
throw new Error(`presign chapter ${slug}/${n}: network error`);
@@ -110,7 +126,7 @@ export async function presignVoiceSample(voice: string): Promise<string> {
log.debug('minio', 'presigning voice sample', { voice });
let res: Response;
try {
res = await fetch(`${SCRAPER_URL}/api/presign/voice-sample/${encodeURIComponent(voice)}`);
res = await backendFetch(`/api/presign/voice-sample/${encodeURIComponent(voice)}`);
} catch (e) {
log.error('minio', 'presign voice sample network error', { voice, err: String(e) });
throw new Error(`presign voice sample ${voice}: network error`);
@@ -146,7 +162,7 @@ export async function presignAudio(
log.debug('minio', 'presigning audio', { slug, n, voice });
let res: Response;
try {
res = await fetch(`${SCRAPER_URL}/api/presign/audio/${slug}/${n}${qs}`);
res = await backendFetch(`/api/presign/audio/${slug}/${n}${qs}`);
} catch (e) {
log.error('minio', 'presign audio network error', { slug, n, err: String(e) });
throw new Error(`presign audio ${slug}/${n}: network error`);

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;
@@ -212,10 +212,6 @@ export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
return listN<Book>('books', limit, '', '-meta_updated');
}
export async function recentlyUpdatedBooks(limit = 6): Promise<Book[]> {
return listN<Book>('books', limit, '', '-meta_updated');
}
export interface HomeStats {
totalBooks: number;
totalChapters: number;
@@ -587,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(
@@ -596,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,
@@ -666,6 +662,8 @@ export async function setAudioTime(
}
// ─── Audio cache ──────────────────────────────────────────────────────────────
// There is no separate audio_cache collection — completed audio jobs in the
// audio_jobs collection serve as the cache record. We project them here.
export interface AudioCacheEntry {
id: string;
@@ -675,7 +673,13 @@ export interface AudioCacheEntry {
}
export async function listAudioCache(): Promise<AudioCacheEntry[]> {
return listAll<AudioCacheEntry>('audio_cache', '', '-updated');
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', '-finished');
return jobs.map((j) => ({
id: j.id,
cache_key: j.cache_key,
filename: `${j.cache_key}.mp3`,
updated: j.finished
}));
}
// ─── Scraping tasks ───────────────────────────────────────────────────────────
@@ -688,6 +692,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;
@@ -698,6 +704,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 {
@@ -854,7 +864,7 @@ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Pr
// ─── Comments ─────────────────────────────────────────────────────────────────
export interface BookComment {
export interface PBBookComment {
id: string;
slug: string;
user_id: string;
@@ -885,7 +895,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)
@@ -900,7 +910,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);
@@ -917,7 +927,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(
@@ -926,7 +936,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[];
}
/**
@@ -939,7 +949,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',
@@ -959,7 +969,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>;
}
/**
@@ -975,7 +985,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
@@ -986,7 +996,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}`, {
@@ -1039,7 +1049,7 @@ export async function voteComment(
vote: 'up' | 'down',
sessionId: string,
userId?: string
): Promise<BookComment> {
): Promise<PBBookComment> {
const token = await getToken();
// Fetch current comment
@@ -1047,7 +1057,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);
@@ -1090,7 +1100,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

@@ -0,0 +1,118 @@
/**
* Valkey-backed presign URL cache (v3).
*
* Replaces the in-process Map from v2. All presign URLs are stored in Valkey
* (Redis-compatible) with native TTL, so:
* - Cache survives UI process restarts.
* - Cache is shared across multiple UI replicas (if scaled horizontally).
* - No manual sweep timer needed — Valkey expires entries automatically.
*
* MinIO presigned audio URLs are valid for 1 hour (set by the backend).
* We cache them for 50 minutes so the browser always gets a URL with at
* least 10 minutes of remaining validity.
*
* Voice-sample URLs use the same cache with key "sample:<voice>".
*
* Connection:
* VALKEY_URL env var (default: redis://valkey:6379)
* ioredis handles reconnection automatically.
*/
import Redis from 'ioredis';
const AUDIO_TTL_S = 50 * 60; // 50 minutes in seconds (Valkey TTL is in seconds)
// Lazily-initialised singleton client.
let _client: Redis | null = null;
function client(): Redis {
if (!_client) {
const url = process.env.VALKEY_URL ?? 'redis://valkey:6379';
_client = new Redis(url, {
// Reconnect automatically with exponential backoff (ioredis default).
// lazyConnect: false means the connection is established immediately.
lazyConnect: false,
// Log connection errors to stderr; do not crash the process.
enableOfflineQueue: true,
maxRetriesPerRequest: 2,
});
_client.on('error', (err: Error) => {
console.error('[presignCache] Valkey error:', err.message);
});
}
return _client;
}
// ── Key helpers ───────────────────────────────────────────────────────────────
/** Cache key for a chapter audio presigned URL. */
export function audioKey(slug: string, n: number, voice: string): string {
return `audio:${slug}:${n}:${voice}`;
}
/** Cache key for a voice-sample presigned URL. */
export function sampleKey(voice: string): string {
return `sample:${voice}`;
}
// ── Public API ────────────────────────────────────────────────────────────────
/** Return the cached URL for key, or null if absent / expired. */
export async function get(key: string): Promise<string | null> {
try {
return await client().get(key);
} catch (err) {
console.error('[presignCache] get error:', err);
return null;
}
}
/** Store a presigned URL under key for ttlSeconds seconds. */
export async function set(key: string, url: string, ttlSeconds = AUDIO_TTL_S): Promise<void> {
try {
await client().set(key, url, 'EX', ttlSeconds);
} catch (err) {
console.error('[presignCache] set error:', err);
}
}
/** Invalidate a specific key (e.g. after audio generation to force refresh). */
export async function invalidate(key: string): Promise<void> {
try {
await client().del(key);
} catch (err) {
console.error('[presignCache] invalidate error:', err);
}
}
/**
* Disconnect from Valkey — called on graceful shutdown.
* ioredis queues commands during reconnects; calling quit() drains the queue
* and closes the connection cleanly.
*/
export async function drain(): Promise<void> {
if (_client) {
try {
await _client.quit();
} catch {
// ignore — process is exiting anyway
}
_client = null;
}
}
/**
* Returns the approximate number of keys matching the libnovel presign prefix.
* Used for health/debug only — not called in the hot path.
*/
export async function size(): Promise<number> {
try {
// DBSIZE returns the total key count in the current DB.
// For a precise count of just our keys, use SCAN with a pattern.
const keys = await client().keys('audio:*');
const sampleKeys = await client().keys('sample:*');
return keys.length + sampleKeys.length;
} catch {
return -1;
}
}

View File

@@ -0,0 +1,60 @@
/**
* Backend API helper.
*
* Centralises the BACKEND_URL constant and provides a thin fetch wrapper that:
* - Resolves paths relative to BACKEND_API_URL.
* - Throws 502 on network errors (unreachable backend).
* - Re-throws SvelteKit `error()` objects so callers can still short-circuit.
* - Passes a RequestInit through verbatim so callers keep full control.
*
* Import only from server-side modules (`+server.ts`, `*.server.ts`).
*/
import { error } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export const BACKEND_URL = env.BACKEND_API_URL ?? 'http://localhost:8080';
/**
* Fetch a path on the backend, throwing a 502 on network failures.
*
* The `path` must start with `/` (e.g. `/api/voices`).
*
* SvelteKit `error()` exceptions are always re-thrown so callers can
* short-circuit correctly inside their own catch blocks.
*/
export async function backendFetch(path: string, init?: RequestInit): Promise<Response> {
try {
return await fetch(`${BACKEND_URL}${path}`, init);
} catch (e) {
// Re-throw SvelteKit HTTP errors so they propagate to the framework.
if (e instanceof Error && 'status' in e) throw e;
throw error(502, 'Could not reach backend');
}
}
// ─── 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 }[];
}

30
ui/src/lib/types.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* Shared domain types for the LibNovel UI.
*
* Server-only types (full PocketBase record shapes) live in
* src/lib/server/pocketbase.ts. This file holds the types that are
* safe to import in both server and client code.
*/
// ── Comments ─────────────────────────────────────────────────────────────────
export interface BookComment {
id: string;
slug: string;
user_id: string;
username: string;
body: string;
upvotes: number;
downvotes: number;
created: string;
parent_id?: string;
replies?: BookComment[];
}
// ── User settings ─────────────────────────────────────────────────────────────
export interface UserSettings {
voice: string;
speed: number;
autoNext: boolean;
}

13
ui/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,13 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Merge Tailwind classes safely, resolving conflicts via tailwind-merge
* and collapsing falsy values via clsx.
*
* Usage:
* cn('px-4 py-2', isActive && 'bg-brand', className)
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

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

@@ -6,6 +6,8 @@
import type { LayoutData } from './$types';
import { audioStore } from '$lib/audio.svelte';
import { env } from '$env/dynamic/public';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
let { children, data }: { children: Snippet; data: LayoutData } = $props();
@@ -168,6 +170,14 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>libnovel</title>
<!-- Umami analytics — no-op when PUBLIC_UMAMI_WEBSITE_ID is unset -->
{#if env.PUBLIC_UMAMI_WEBSITE_ID}
<script
defer
src="https://analytics.libnovel.cc/script.js"
data-website-id={env.PUBLIC_UMAMI_WEBSITE_ID}
></script>
{/if}
</svelte:head>
<!-- Persistent audio element — always in the DOM, never conditionally unmounted.
@@ -230,12 +240,20 @@
>
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'}"
>
Discover
</a>
<a
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>
<a
href="https://feedback.libnovel.cc"
target="_blank"
rel="noopener noreferrer"
class="hidden sm:block text-sm transition-colors text-zinc-400 hover:text-zinc-100"
>
Feedback
</a>
<div class="ml-auto flex items-center gap-4">
<!-- Desktop: admin + profile + sign out (hidden on mobile) -->
@@ -248,15 +266,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
@@ -266,17 +278,19 @@
{data.user.username}
</a>
<form method="POST" action="/logout" class="hidden sm:block">
<button type="submit" class="text-zinc-400 hover:text-zinc-100 text-sm transition-colors">
<Button type="submit" variant="ghost" size="sm" class="text-zinc-400 hover:text-zinc-100">
Sign out
</button>
</Button>
</form>
<!-- Mobile: hamburger button -->
<button
<Button
variant="ghost"
size="icon"
onclick={() => (menuOpen = !menuOpen)}
aria-label="Toggle menu"
aria-expanded={menuOpen}
class="sm:hidden p-2 -mr-1 rounded text-zinc-400 hover:text-zinc-100 transition-colors"
class="sm:hidden -mr-1"
>
{#if menuOpen}
<!-- X icon -->
@@ -289,7 +303,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
{/if}
</button>
</Button>
</div>
{:else}
<div class="ml-auto">
@@ -313,13 +327,22 @@
>
Library
</a>
<a
href="/browse"
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'}"
>
Discover
</a>
<a
href="/catalogue"
onclick={() => (menuOpen = false)}
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>
<a
href="https://feedback.libnovel.cc"
target="_blank"
rel="noopener noreferrer"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
>
Feedback ↗
</a>
<a
href="/profile"
onclick={() => (menuOpen = false)}
@@ -352,15 +375,16 @@
Audio jobs
</a>
{/if}
<div class="my-1 border-t border-zinc-700/60"></div>
<form method="POST" action="/logout">
<button
type="submit"
class="w-full text-left px-3 py-2.5 rounded-lg text-sm font-medium text-red-400 hover:bg-zinc-800 transition-colors"
>
Sign out
</button>
</form>
<div class="my-1 border-t border-zinc-700/60"></div>
<form method="POST" action="/logout">
<Button
type="submit"
variant="ghost"
class="w-full justify-start px-3 py-2.5 h-auto text-sm font-medium text-red-400 hover:bg-zinc-800 hover:text-red-300"
>
Sign out
</Button>
</form>
</div>
{/if}
</header>
@@ -375,10 +399,22 @@
<div class="max-w-6xl mx-auto px-4 py-6 flex flex-col items-center gap-4 text-xs text-zinc-600">
<!-- 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="https://novelfire.net"
<a href="/books" class="hover:text-zinc-400 transition-colors">Library</a>
<a href="/catalogue" class="hover:text-zinc-400 transition-colors">Discover</a>
<a
href="https://feedback.libnovel.cc"
target="_blank"
rel="noopener noreferrer"
class="hover:text-zinc-400 transition-colors flex items-center gap-1"
>
Feedback
<svg class="w-3 h-3" 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>
<a
href="https://novelfire.net"
target="_blank"
rel="noopener noreferrer"
class="hover:text-zinc-400 transition-colors flex items-center gap-1"
@@ -414,15 +450,17 @@
<div class="max-w-6xl mx-auto px-4">
<div class="flex items-center justify-between py-2 border-b border-zinc-800 sticky top-0 bg-zinc-900">
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Chapters</span>
<button
<Button
variant="ghost"
size="icon"
onclick={() => (chapterDrawerOpen = false)}
class="text-zinc-600 hover:text-zinc-300 transition-colors p-1"
aria-label="Close chapter list"
class="h-6 w-6 text-zinc-600 hover:text-zinc-300"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
</Button>
</div>
{#each audioStore.chapters as ch (ch.number)}
<a
@@ -500,9 +538,10 @@
{#if audioStore.status === 'ready'}
<!-- Skip back 15s -->
<button
<Button
variant="ghost"
size="icon"
onclick={skipBack}
class="text-zinc-400 hover:text-zinc-100 transition-colors p-1.5 rounded"
title="Back 15s"
aria-label="Rewind 15 seconds"
>
@@ -510,9 +549,9 @@
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">15</text>
</svg>
</button>
</Button>
<!-- Play / Pause -->
<!-- Play / Pause — custom circular amber style, kept as raw button -->
<button
onclick={togglePlay}
class="w-10 h-10 rounded-full bg-amber-400 text-zinc-900 flex items-center justify-center hover:bg-amber-300 transition-colors flex-shrink-0"
@@ -530,9 +569,10 @@
</button>
<!-- Skip forward 30s -->
<button
<Button
variant="ghost"
size="icon"
onclick={skipForward}
class="text-zinc-400 hover:text-zinc-100 transition-colors p-1.5 rounded"
title="Forward 30s"
aria-label="Skip 30 seconds"
>
@@ -540,9 +580,9 @@
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">30</text>
</svg>
</button>
</Button>
<!-- Speed control -->
<!-- Speed control — fixed-width pill, kept as raw button -->
<button
onclick={cycleSpeed}
class="text-xs font-semibold text-zinc-300 hover:text-amber-400 transition-colors px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 flex-shrink-0 tabular-nums w-12 text-center"
@@ -552,12 +592,15 @@
{audioStore.speed}×
</button>
<!-- Auto-next toggle (with prefetch indicator) -->
<!-- Auto-next toggle — has absolute-positioned status dots, kept as raw button -->
<button
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
class="relative p-1.5 rounded flex-shrink-0 transition-colors {audioStore.autoNext
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
: 'text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800'}"
class={cn(
'relative p-1.5 rounded flex-shrink-0 transition-colors',
audioStore.autoNext
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
: 'text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800'
)}
title={audioStore.autoNext
? audioStore.nextStatus === 'prefetched'
? `Auto-next on Ch.${audioStore.nextChapter} ready`
@@ -613,16 +656,18 @@
{/if}
<!-- Dismiss -->
<button
<Button
variant="ghost"
size="icon"
onclick={dismiss}
class="text-zinc-600 hover:text-zinc-400 transition-colors p-1.5 rounded flex-shrink-0"
title="Close player"
aria-label="Close player"
class="text-zinc-600 hover:text-zinc-400 flex-shrink-0"
>
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</Button>
</div>
</div>
{/if}

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

@@ -0,0 +1,56 @@
<script lang="ts">
import { page } from '$app/state';
const adminTabs = [
{ href: '/admin/scrape', label: 'Scrape' },
{ href: '/admin/audio', label: 'Audio' }
];
const toolTabs = [
{ href: 'https://feedback.libnovel.cc', label: 'Feedback' },
{ href: 'https://errors.libnovel.cc', label: 'Errors' },
{ href: 'https://analytics.libnovel.cc', label: 'Analytics' },
{ href: 'https://logs.libnovel.cc', label: 'Logs' },
{ href: 'https://uptime.libnovel.cc', label: 'Uptime' },
{ href: 'https://push.libnovel.cc', label: 'Push' }
];
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
</script>
<!-- Admin nav: internal pages + external tools -->
<div class="mb-6 flex flex-wrap items-center gap-3">
<!-- Internal admin pages -->
<div class="flex gap-1 bg-zinc-800 rounded-lg p-1 border border-zinc-700">
{#each adminTabs as tab}
<a
href={tab.href}
class="px-4 py-1.5 rounded-md text-sm font-medium transition-colors
{page.url.pathname.startsWith(tab.href)
? 'bg-zinc-700 text-zinc-100'
: 'text-zinc-400 hover:text-zinc-200'}"
>
{tab.label}
</a>
{/each}
</div>
<!-- External tools (open in new tab) -->
<div class="flex gap-1 bg-zinc-800 rounded-lg p-1 border border-zinc-700">
{#each toolTabs as tool}
<a
href={tool.href}
target="_blank"
rel="noopener noreferrer"
class="px-4 py-1.5 rounded-md text-sm font-medium text-zinc-400 hover:text-zinc-200 transition-colors"
>
{tool.label}
</a>
{/each}
</div>
</div>
{@render children?.()}

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,152 +0,0 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let jobs = $state(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,18 +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(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) {
@@ -22,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

@@ -1,10 +1,8 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { listScrapingTasks } from '$lib/server/pocketbase';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
import { backendFetch } from '$lib/server/scraper';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user?.role !== 'admin') {
@@ -16,7 +14,7 @@ export const load: PageServerLoad = async ({ locals }) => {
log.warn('admin/scrape', 'failed to load tasks', { err: String(e) });
return [];
}),
fetch(`${SCRAPER_URL}/api/scrape/status`).catch(() => null)
backendFetch('/api/scrape/status').catch(() => null)
]);
let running = false;

View File

@@ -1,13 +1,14 @@
<script lang="ts">
import { untrack } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import type { ScrapingTask } from '$lib/server/pocketbase';
let { data }: { data: PageData } = $props();
// ── Live-poll status ────────────────────────────────────────────────────────
let running = $state(data.running);
let tasks = $state(data.tasks);
let polling = $state(false);
let running = $state(untrack(() => data.running));
let tasks = $state(untrack(() => data.tasks));
// Poll every 5 s while a job is running
$effect(() => {
@@ -18,7 +19,6 @@
const body = await res.json().catch(() => null);
running = body?.running ?? false;
if (!running) {
// Refresh tasks list once job finishes
await invalidateAll();
}
}
@@ -32,28 +32,54 @@
tasks = data.tasks;
});
// ── Trigger scrape ──────────────────────────────────────────────────────────
// ── Full catalogue scrape ───────────────────────────────────────────────────
let catalogueError = $state('');
let cataloguing = $state(false);
async function triggerCatalogueScrape() {
if (running || cataloguing) return;
cataloguing = true;
catalogueError = '';
try {
const res = await fetch('/api/scrape', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
catalogueError = d.error ?? d.message ?? `Error ${res.status}`;
} else {
running = true;
}
} catch {
catalogueError = 'Network error.';
} finally {
cataloguing = false;
}
}
// ── 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.';
@@ -62,6 +88,108 @@
}
}
// ── 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 === 'catalogue') {
triggerCatalogueScrape();
} else 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 {
scrapeUrl = task.target_url ?? '';
scrollToBookForm();
}
}
// ── Cancel task ─────────────────────────────────────────────────────────────
let cancellingIds = $state(new Set<string>());
let cancelErrors: Record<string, string> = $state({});
async function cancelTask(id: string) {
if (cancellingIds.has(id)) return;
cancellingIds = new Set([...cancellingIds, id]);
delete cancelErrors[id];
try {
const res = await fetch(`/api/scrape/cancel/${encodeURIComponent(id)}`, { method: 'POST' });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
cancelErrors = { ...cancelErrors, [id]: body.error ?? body.message ?? `Error ${res.status}` };
} else {
tasks = tasks.map((t: ScrapingTask) => (t.id === id ? { ...t, status: 'cancelled' } : t));
}
} catch {
cancelErrors = { ...cancelErrors, [id]: 'Network error.' };
} finally {
cancellingIds = new Set([...cancellingIds].filter((x) => x !== id));
}
}
// ── 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';
@@ -87,6 +215,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>
@@ -94,6 +232,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>
@@ -106,90 +245,269 @@
{/if}
</p>
</div>
</div>
<!-- Trigger controls -->
<div class="flex flex-wrap gap-3 items-start">
<!-- Scrape controls -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Full catalogue -->
<div class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
<div>
<h2 class="text-sm font-semibold text-zinc-300">Scrape full catalogue</h2>
<p class="text-xs text-zinc-500 mt-1">Re-crawls all novelfire.net pages and picks up new books.</p>
</div>
<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"
onclick={triggerCatalogueScrape}
disabled={running || cataloguing}
class="w-full px-4 py-2 rounded-lg bg-amber-600 text-zinc-900 font-semibold text-sm hover:bg-amber-500 transition-colors disabled:opacity-50"
>
Full catalogue scrape
{cataloguing ? 'Queuing…' : running ? 'Already running…' : 'Start catalogue scrape'}
</button>
{#if catalogueError}
<p class="text-sm text-red-400">{catalogueError}</p>
{/if}
</div>
<!-- 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>
</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>
</tr>
{#if task.error_message}
<tr class="bg-red-950/20">
<td colspan="8" 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'}
<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'}
<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

@@ -1,19 +1,17 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
import { backendFetch } from '$lib/server/scraper';
/**
* GET /api/admin/scrape/status
* Admin-only proxy to the Go scraper's /api/scrape/status endpoint.
* Admin-only proxy to the Go backend's /api/scrape/status endpoint.
*/
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
try {
const res = await fetch(`${SCRAPER_URL}/api/scrape/status`);
const res = await backendFetch('/api/scrape/status');
if (!res.ok) return json({ running: false });
const data = await res.json();
return json({ running: data.running ?? false });

View File

@@ -1,21 +1,19 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
import { backendFetch } from '$lib/server/scraper';
/**
* POST /api/audio/[slug]/[n]
* Proxies the audio generation request to the scraper's /api/audio endpoint.
* Keeps the scraper URL server-side — the browser never needs to know it.
* Proxies the audio generation request to the backend's /api/audio endpoint.
* Keeps the backend URL server-side — the browser never needs to know it.
*
* Body: { voice?: string }
*
* Responses:
* 200 { url: string, filename: string } — audio already cached; url is a
* relative path to GET /api/audio/[slug]/[n]?voice=...
* 202 { job_id: string, status: "pending"|"generating" } — generation
* 200 { status: "done" } — audio already cached; client should call
* GET /api/presign/audio to obtain a direct MinIO presigned URL.
* 202 { task_id: string, status: "pending"|"generating" } — generation
* enqueued; poll GET /api/audio/status/[slug]/[n]?voice=... until done.
*/
export const POST: RequestHandler = async ({ params, request }) => {
@@ -32,7 +30,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
// empty body is fine — scraper will use defaults
}
const scraperRes = await fetch(`${SCRAPER_URL}/api/audio/${slug}/${chapter}`, {
const scraperRes = await backendFetch(`/api/audio/${slug}/${chapter}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
@@ -40,66 +38,28 @@ export const POST: RequestHandler = async ({ params, request }) => {
if (!scraperRes.ok) {
const text = await scraperRes.text().catch(() => '');
log.error('audio', 'scraper audio generation failed', { slug, chapter, status: scraperRes.status, body: text });
log.error('audio', 'backend audio generation failed', { slug, chapter, status: scraperRes.status, body: text });
error(scraperRes.status as Parameters<typeof error>[0], text || 'Audio generation failed');
}
const data = (await scraperRes.json()) as
| { url: string; filename: string }
| { job_id: string; status: string };
| { url: string; status: 'done' }
| { task_id: string; status: string };
const voice = body.voice ?? '';
const qs = new URLSearchParams();
if (voice) qs.set('voice', voice);
// 202 Accepted: generation enqueued — return job_id + status for polling.
if (scraperRes.status === 202 || 'job_id' in data) {
// 202 Accepted: generation enqueued — return task_id + status for polling.
if (scraperRes.status === 202 || 'task_id' in data) {
return new Response(JSON.stringify(data), {
status: 202,
headers: { 'Content-Type': 'application/json' }
});
}
// 200: audio was already cached — rewrite the proxy URL through our own handler.
const cached = data as { url: string; filename: string };
// 200: audio was already cached.
// Return status only — no url — so the client calls GET /api/presign/audio
// and streams directly from MinIO instead of through the Node.js server.
return new Response(
JSON.stringify({
url: `/api/audio/${slug}/${chapter}?${qs.toString()}`,
filename: cached.filename
}),
JSON.stringify({ status: 'done' }),
{ headers: { 'Content-Type': 'application/json' } }
);
};
/**
* GET /api/audio/[slug]/[n]?voice=...
* Proxies the audio stream from the scraper's /api/audio-proxy endpoint.
* This is the URL the browser's <audio> element uses as its src.
*/
export const GET: RequestHandler = async ({ params, url }) => {
const { slug, n } = params;
const chapter = parseInt(n, 10);
if (!slug || !chapter || chapter < 1) {
error(400, 'Invalid slug or chapter number');
}
const voice = url.searchParams.get('voice') ?? '';
const qs = new URLSearchParams();
if (voice) qs.set('voice', voice);
const scraperRes = await fetch(`${SCRAPER_URL}/api/audio-proxy/${slug}/${chapter}?${qs.toString()}`);
if (!scraperRes.ok) {
log.error('audio', 'scraper audio proxy failed', { slug, chapter, status: scraperRes.status });
error(scraperRes.status as Parameters<typeof error>[0], 'Audio not found');
}
// Stream the audio body through — preserve Content-Type and Content-Length.
const headers = new Headers();
headers.set('Content-Type', scraperRes.headers.get('Content-Type') ?? 'audio/mpeg');
headers.set('Cache-Control', 'public, max-age=3600');
const cl = scraperRes.headers.get('Content-Length');
if (cl) headers.set('Content-Length', cl);
return new Response(scraperRes.body, { headers });
};

View File

@@ -1,24 +1,22 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
import { backendFetch } from '$lib/server/scraper';
/**
* GET /api/audio/status/[slug]/[n]?voice=...
* Proxies the audio generation status check to the scraper's
* Proxies the audio generation status check to the backend's
* GET /api/audio/status/{slug}/{n} endpoint.
*
* Possible responses from scraper (passed through as-is):
* {"status":"done","url":"/api/audio-proxy/...","filename":"..."}
* {"status":"pending"|"generating","job_id":"..."}
* {"status":"idle"}
* {"status":"failed","error":"..."}
* Possible responses passed through to the client:
* {"status":"done"} — audio ready; no url
* {"status":"pending"|"generating","task_id":"..."} — in progress
* {"status":"idle"} — no job yet
* {"status":"failed","error":"..."} — last job failed
*
* When status is "done" the scraper returns a proxy URL pointing to its own
* /api/audio-proxy/... — we rewrite this to our own
* /api/audio/[slug]/[n]?voice=... so the browser never calls the scraper.
* When status is "done" the scraper's internal proxy URL is stripped — the
* client must call GET /api/presign/audio to obtain a direct MinIO presigned
* URL. This avoids streaming audio through the Node.js server.
*/
export const GET: RequestHandler = async ({ params, url }) => {
const { slug, n } = params;
@@ -31,13 +29,13 @@ export const GET: RequestHandler = async ({ params, url }) => {
const qs = new URLSearchParams();
if (voice) qs.set('voice', voice);
const scraperRes = await fetch(
`${SCRAPER_URL}/api/audio/status/${slug}/${chapter}?${qs.toString()}`
const scraperRes = await backendFetch(
`/api/audio/status/${slug}/${chapter}?${qs.toString()}`
);
if (!scraperRes.ok) {
const text = await scraperRes.text().catch(() => '');
log.error('audio', 'scraper audio status check failed', {
log.error('audio', 'backend audio status check failed', {
slug,
chapter,
status: scraperRes.status,
@@ -48,17 +46,16 @@ export const GET: RequestHandler = async ({ params, url }) => {
const data = (await scraperRes.json()) as {
status: string;
job_id?: string;
task_id?: string;
url?: string;
filename?: string;
error?: string;
};
// Rewrite the proxy URL if the audio is done so it routes through us.
if (data.status === 'done' && data.url) {
const rewrittenQs = new URLSearchParams();
if (voice) rewrittenQs.set('voice', voice);
data.url = `/api/audio/${slug}/${chapter}?${rewrittenQs.toString()}`;
// Strip the backend's internal proxy URL from "done" responses.
// The client will call GET /api/presign/audio to get a direct MinIO URL,
// avoiding streaming audio through the Node.js server.
if (data.status === 'done') {
delete data.url;
}
return new Response(JSON.stringify(data), {

View File

@@ -1,33 +0,0 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
/**
* POST /api/audio/voice-samples
* Triggers generation of voice sample audio files for all (or specified) voices.
* Proxies to the scraper's POST /api/audio/voice-samples endpoint.
* Optional body: { voices: string[] } to generate a subset.
* Returns: { generated: string[], skipped: string[], failed: string[] }
*/
export const POST: RequestHandler = async ({ request }) => {
let body: { voices?: string[] } = {};
try {
body = await request.json();
} catch {
// Empty body is fine — generates all voices
}
try {
const res = await fetch(`${SCRAPER_URL}/api/audio/voice-samples`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
return json(data, { status: res.ok ? 200 : res.status });
} catch (e) {
return json({ error: String(e) }, { status: 502 });
}
};

View File

@@ -2,22 +2,15 @@ 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 { env } from '$env/dynamic/private';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
interface PreviewChapter {
number: number;
title: string;
url: string;
}
import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper';
/**
* GET /api/book/[slug]
* Returns book metadata, chapter list, progress, and library status.
* Falls back to a live scraper preview if the book is not in PocketBase.
*
* Response shape mirrors BookDetailResponse in the iOS APIClient.
* If the book is not yet in PocketBase, asks the backend to enqueue a scrape
* task and returns 202 with { scraping: true, task_id }.
* The client should poll and retry once the task completes.
*/
export const GET: RequestHandler = async ({ params, locals }) => {
const { slug } = params;
@@ -44,35 +37,31 @@ export const GET: RequestHandler = async ({ params, locals }) => {
return json({
book,
chapters,
preview_chapters: null,
in_lib: true,
saved,
last_chapter: progress?.chapter ?? null
last_chapter: progress?.chapter ?? null,
scraping: false,
task_id: null
});
}
// Fall back to live scraper preview
// Fall back to backend: enqueue scrape task if not in library.
try {
const res = await fetch(`${SCRAPER_URL}/api/book-preview/${encodeURIComponent(slug)}`);
const res = await backendFetch(`/api/book-preview/${encodeURIComponent(slug)}`);
if (res.status === 202) {
const body: { task_id: string; message: string } = await res.json();
log.info('api/book', 'scrape task enqueued', { slug, task_id: body.task_id });
return json({ scraping: true, task_id: body.task_id, in_lib: false }, { status: 202 });
}
if (!res.ok) {
log.warn('api/book', 'book-preview returned error', { slug, status: res.status });
error(404, `Book "${slug}" not found`);
}
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: PreviewChapter[];
} = await res.json();
// 200 — book was already in library
const preview: BookPreviewResponse = await res.json();
const previewBook = {
id: '',
@@ -91,11 +80,12 @@ export const GET: RequestHandler = async ({ params, locals }) => {
return json({
book: previewBook,
chapters: [],
preview_chapters: preview.chapters,
in_lib: preview.in_lib,
chapters: preview.chapters,
in_lib: true,
saved: false,
last_chapter: null
last_chapter: null,
scraping: false,
task_id: null
});
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;

View File

@@ -1,37 +0,0 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
/**
* GET /api/browse-page?page=2&genre=all&sort=popular&status=all
*
* Thin proxy to the Go scraper's /api/browse endpoint.
* Used by the infinite-scroll browse page to append subsequent pages
* without a full SSR navigation.
*/
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 params = new URLSearchParams({ page, genre, sort, status });
const apiURL = `${SCRAPER_URL}/api/browse?${params.toString()}`;
try {
const res = await fetch(apiURL);
if (!res.ok) {
log.error('browse-page', 'scraper returned error', { status: res.status });
throw error(502, `Browse fetch failed: ${res.status}`);
}
const data = await res.json();
return json(data);
} 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,13 +1,11 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
import { backendFetch } from '$lib/server/scraper';
/**
* GET /api/chapter-text-preview/[slug]/[n]
* Proxies to the scraper's /api/chapter-text-preview endpoint.
* Proxies to the backend's /api/chapter-text-preview endpoint.
* Used client-side when the normal chapter path returns no content
* (chapter indexed but not yet scraped to MinIO).
*/
@@ -25,8 +23,8 @@ export const GET: RequestHandler = async ({ params, url }) => {
if (chapterUrl) qs.set('chapter_url', chapterUrl);
if (title) qs.set('title', title);
const scraperRes = await fetch(
`${SCRAPER_URL}/api/chapter-text-preview/${encodeURIComponent(slug)}/${chapter}?${qs.toString()}`
const scraperRes = await backendFetch(
`/api/chapter-text-preview/${encodeURIComponent(slug)}/${chapter}?${qs.toString()}`
).catch((e) => {
log.error('chapter-preview', 'scraper fetch failed', { slug, chapter, err: String(e) });
return null;

View File

@@ -2,11 +2,8 @@ import { json, error } from '@sveltejs/kit';
import { marked } from 'marked';
import type { RequestHandler } from './$types';
import { getBook, listChapterIdx } from '$lib/server/pocketbase';
import { presignChapter } from '$lib/server/minio';
import { log } from '$lib/server/logger';
import { env } from '$env/dynamic/private';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
import { backendFetch } from '$lib/server/scraper';
/**
* GET /api/chapter/[slug]/[n]
@@ -33,9 +30,9 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
let chapterData: { slug: string; number: number; title: string; text: string; url: string };
try {
const res = await fetch(
`${SCRAPER_URL}/api/chapter-text-preview/${encodeURIComponent(slug)}/${n}?${previewParams.toString()}`
);
const res = await backendFetch(
`/api/chapter-text-preview/${encodeURIComponent(slug)}/${n}?${previewParams.toString()}`
);
if (!res.ok) {
log.error('api/chapter', 'chapter-text-preview returned error', { slug, n, status: res.status });
error(404, `Chapter ${n} not found`);
@@ -53,7 +50,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
let voices: string[] = [];
try {
const vRes = await fetch(`${SCRAPER_URL}/api/voices`);
const vRes = await backendFetch('/api/voices');
if (vRes.ok) {
const d = (await vRes.json()) as { voices: string[] };
voices = d.voices ?? [];
@@ -80,7 +77,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
const [book, chapters, voicesRes] = await Promise.all([
getBook(slug),
listChapterIdx(slug),
fetch(`${SCRAPER_URL}/api/voices`).catch(() => null)
backendFetch('/api/voices').catch(() => null)
]);
if (!book) error(404, `Book "${slug}" not found`);
@@ -100,13 +97,17 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
let html = '';
try {
const presignUrl = await presignChapter(slug, n);
const res = await fetch(presignUrl);
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
const res = await backendFetch(`/api/chapter-markdown/${encodeURIComponent(slug)}/${n}`);
if (!res.ok) {
log.error('api/chapter', 'chapter-markdown returned error', { slug, n, status: res.status });
error(res.status === 404 ? 404 : 502, res.status === 404 ? `Chapter ${n} not found` : 'Could not fetch chapter content');
}
const markdown = await res.text();
html = marked(markdown) as string;
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
log.error('api/chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
error(502, 'Could not fetch chapter content');
}
const prevChapter = chapters.find((c) => c.number === n - 1) ?? null;

View File

@@ -2,32 +2,99 @@ import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { presignAudio } from '$lib/server/minio';
import { log } from '$lib/server/logger';
import * as cache from '$lib/server/presignCache';
import { backendFetch } from '$lib/server/scraper';
/**
* GET /api/presign/audio?slug=...&n=...&voice=...
* Returns a presigned MinIO URL for the audio file so the browser
* can stream it directly without going through the server.
* Returns 404 when the audio has not been generated yet.
*
* Returns a presigned MinIO URL for the audio file so the client can stream
* it directly without going through the server.
*
* When the audio has not been generated yet, this endpoint automatically
* enqueues a TTS generation job and returns 202 Accepted with the job status,
* so callers can poll GET /api/audio/status/{slug}/{n}?voice=... until done,
* then call this endpoint again to get the URL.
*
* Responses:
* 200 { url: string } — audio ready, stream from MinIO
* 202 { task_id: string, status: string } — TTS enqueued, poll for completion
* 202 { status: "pending"|"generating" } — TTS already in progress
*
* Results are cached in-process for 50 minutes (MinIO URLs are valid 1 hour)
* to avoid a backend + MinIO round-trip on every "Play" click.
*/
export const GET: RequestHandler = async ({ url }) => {
const slug = url.searchParams.get('slug');
// Accept both 'n' (web) and 'chapter' (iOS) as the chapter number param
const n = parseInt(url.searchParams.get('n') ?? url.searchParams.get('chapter') ?? '', 10);
const voice = url.searchParams.get('voice') ?? undefined;
const voice = url.searchParams.get('voice') ?? '';
if (!slug || !n || n < 1) {
error(400, 'Missing slug or n');
}
const cacheKey = cache.audioKey(slug, n, voice);
// Fast path: return cached URL if still valid.
const cached = await cache.get(cacheKey);
if (cached) {
return json({ url: cached });
}
// Slow path: call backend → MinIO presign.
try {
const presignedUrl = await presignAudio(slug, n, voice);
const presignedUrl = await presignAudio(slug, n, voice || undefined);
await cache.set(cacheKey, presignedUrl);
return json({ url: presignedUrl });
} catch (e) {
const status = (e as { status?: number }).status;
if (status === 404) {
error(404, 'Audio not found');
if (status !== 404) {
log.error('presign', 'presign audio failed', { slug, n, err: String(e) });
error(500, `Could not get presigned URL: ${e}`);
}
log.error('presign', 'presign audio failed', { slug, n, err: String(e) });
error(500, `Could not get presigned URL: ${e}`);
}
// Audio not found — automatically trigger TTS generation so the caller
// doesn't need a separate POST step. Return 202 with the job status.
log.info('presign', 'audio not found, triggering TTS generation', { slug, n, voice });
try {
const triggerRes = await backendFetch(`/api/audio/${slug}/${n}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(voice ? { voice } : {})
});
if (!triggerRes.ok) {
const text = await triggerRes.text().catch(() => '');
log.error('presign', 'audio trigger failed', { slug, n, status: triggerRes.status, body: text });
error(triggerRes.status as Parameters<typeof error>[0], text || 'Audio generation failed');
}
const data = (await triggerRes.json()) as
| { url: string; status: 'done' }
| { task_id: string; status: string };
// If the backend says it's already done (race: generated between presign
// check and the POST), try to presign once more and return 200.
if (triggerRes.status === 200 || ('status' in data && data.status === 'done')) {
try {
const presignedUrl = await presignAudio(slug, n, voice || undefined);
await cache.set(cacheKey, presignedUrl);
return json({ url: presignedUrl });
} catch {
// Ignore — fall through to 202 below.
}
}
// Generation is in progress — return 202 so the caller can poll.
return new Response(JSON.stringify(data), {
status: 202,
headers: { 'Content-Type': 'application/json' }
});
} catch (e) {
if ((e as { status?: number }).status) throw e; // re-throw SvelteKit errors
log.error('presign', 'audio trigger error', { slug, n, err: String(e) });
error(500, `Could not trigger audio generation: ${e}`);
}
};

View File

@@ -1,11 +1,18 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { presignVoiceSample } from '$lib/server/minio';
import { log } from '$lib/server/logger';
import * as cache from '$lib/server/presignCache';
/**
* GET /api/presign/voice-sample?voice=af_bella
* Returns a presigned URL for the voice sample audio file.
* Returns 404 if the sample has not been generated yet.
*
* The backend generates the sample on demand via Kokoro TTS if it does not
* exist yet, so this endpoint always returns 200 { url } (or 5xx on failure).
*
* Results are cached in-process for 50 minutes to avoid a backend + MinIO
* round-trip on every voice-selection preview play.
*/
export const GET: RequestHandler = async ({ url }) => {
const voice = url.searchParams.get('voice');
@@ -13,14 +20,21 @@ export const GET: RequestHandler = async ({ url }) => {
error(400, 'Missing voice parameter');
}
const cacheKey = cache.sampleKey(voice);
// Fast path: return cached URL if still valid.
const cached = await cache.get(cacheKey);
if (cached) {
return json({ url: cached });
}
// Slow path: call backend → generate if needed → MinIO presign.
try {
const presignedUrl = await presignVoiceSample(voice);
await cache.set(cacheKey, presignedUrl);
return json({ url: presignedUrl });
} catch (e) {
const status = (e as { status?: number }).status;
if (status === 404) {
error(404, 'Voice sample not found');
}
log.error('presign', 'presign voice sample failed', { voice, err: String(e) });
error(502, `Failed to presign voice sample: ${e}`);
}
};

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

@@ -1,20 +1,18 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
import { backendFetch } from '$lib/server/scraper';
/**
* GET /api/ranking
* Proxies to the Go scraper's /api/ranking endpoint.
* Proxies to the Go backend's /api/ranking endpoint.
* Returns the top-ranked novels list as JSON.
*/
export const GET: RequestHandler = async () => {
try {
const res = await fetch(`${SCRAPER_URL}/api/ranking`);
const res = await backendFetch('/api/ranking');
if (!res.ok) {
log.error('api/ranking', 'scraper returned error', { status: res.status });
log.error('api/ranking', 'backend returned error', { status: res.status });
error(502, `Ranking fetch failed: ${res.status}`);
}
const data = await res.json();

View File

@@ -1,7 +1,7 @@
/**
* POST /api/scrape
*
* Proxies scrape requests to the Go scraper backend.
* Proxies scrape requests to the Go backend.
* Admin-only — returns 403 if the authenticated user is not an admin.
*
* Request body (JSON):
@@ -17,10 +17,8 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
// Admin guard
@@ -39,26 +37,25 @@ export const POST: RequestHandler = async ({ request, locals }) => {
const isBookScrape = typeof body.url === 'string' && body.url.length > 0;
const endpoint = isBookScrape ? '/scrape/book' : '/scrape';
const upstream = `${SCRAPER_URL}${endpoint}`;
let res: Response;
try {
res = await fetch(upstream, {
res = await backendFetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: isBookScrape ? JSON.stringify({ url: body.url }) : undefined
});
} catch (e) {
log.error('scrape', 'scraper proxy network error', { endpoint, err: String(e) });
throw error(502, 'Could not reach scraper');
log.error('scrape', 'backend proxy network error', { endpoint, err: String(e) });
throw error(502, 'Could not reach backend');
}
if (!res.ok && res.status >= 500) {
const text = await res.text().catch(() => '');
log.error('scrape', 'scraper returned error', { endpoint, status: res.status, body: text });
log.error('scrape', 'backend returned error', { endpoint, status: res.status, body: text });
}
const data = await res.json().catch(() => ({}));
// Pass through the status code from the Go scraper (202, 409, 400, …)
// Pass through the status code from the Go backend (202, 409, 400, …)
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,40 @@
/**
* POST /api/scrape/cancel/[id]
*
* Admin-only proxy that cancels a pending scrape (or audio) task by ID.
* Forwards the request to the Go backend POST /api/cancel-task/{id}.
*
* Responses:
* 200 OK — task cancelled
* 403 Forbidden — not an admin
* 409 Conflict — task cannot be cancelled (already running/done/not found)
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ params, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const { id } = params;
if (!id) {
throw error(400, 'Missing task id');
}
let res: Response;
try {
res = await backendFetch(`/api/cancel-task/${encodeURIComponent(id)}`, {
method: 'POST'
});
} catch (e) {
log.error('scrape/cancel', 'network error cancelling task', { id, err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -1,7 +1,7 @@
/**
* POST /api/scrape/range
*
* Proxies range-scrape requests to the Go scraper backend at POST /scrape/book/range.
* Proxies range-scrape requests to the Go backend at POST /scrape/book/range.
* Admin-only.
*
* Request body (JSON):
@@ -17,10 +17,8 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
import { backendFetch } from '$lib/server/scraper';
export const POST: RequestHandler = async ({ request, locals }) => {
// Admin guard
@@ -39,22 +37,21 @@ export const POST: RequestHandler = async ({ request, locals }) => {
throw error(400, 'url and from are required');
}
const upstream = `${SCRAPER_URL}/scrape/book/range`;
let res: Response;
try {
res = await fetch(upstream, {
res = await backendFetch('/scrape/book/range', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: body.url, from: body.from, to: body.to })
});
} catch (e) {
log.error('scrape/range', 'scraper proxy network error', { err: String(e) });
throw error(502, 'Could not reach scraper');
log.error('scrape/range', 'backend proxy network error', { err: String(e) });
throw error(502, 'Could not reach backend');
}
if (!res.ok && res.status >= 500) {
const text = await res.text().catch(() => '');
log.error('scrape/range', 'scraper returned error', { status: res.status, body: text });
log.error('scrape/range', 'backend returned error', { status: res.status, body: text });
}
const data = await res.json().catch(() => ({}));

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

@@ -1,13 +1,11 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
import { backendFetch } from '$lib/server/scraper';
/**
* GET /api/search?q=<query>
* Proxies to the Go scraper's /api/search endpoint.
* Proxies to the Go backend's /api/search endpoint.
* Returns: { results, local_count, remote_count }
*
* Response shape mirrors SearchResponse in the iOS APIClient.
@@ -19,11 +17,11 @@ export const GET: RequestHandler = async ({ url }) => {
return json({ results: [], local_count: 0, remote_count: 0 });
}
const apiURL = `${SCRAPER_URL}/api/search?q=${encodeURIComponent(q.trim())}`;
const apiURL = `/api/search?q=${encodeURIComponent(q.trim())}`;
try {
const res = await fetch(apiURL);
const res = await backendFetch(apiURL);
if (!res.ok) {
log.error('api/search', 'scraper returned error', { status: res.status, q });
log.error('api/search', 'backend returned error', { status: res.status, q });
error(502, `Search failed: ${res.status}`);
}
const data = await res.json();
@@ -31,6 +29,6 @@ export const GET: RequestHandler = async ({ url }) => {
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
log.error('api/search', 'network error', { q, err: String(e) });
error(502, 'Could not reach search service');
error(502, 'Could not reach backend');
}
};

View File

@@ -1,17 +1,15 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
import { backendFetch } from '$lib/server/scraper';
/**
* GET /api/voices
* Proxies the voice list from the scraper → Kokoro.
* Proxies the voice list from the backend → Kokoro.
* Returns { voices: string[] }
*/
export const GET: RequestHandler = async () => {
try {
const res = await fetch(`${SCRAPER_URL}/api/voices`);
const res = await backendFetch('/api/voices');
if (!res.ok) {
return json({ voices: [] });
}

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,16 +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 { env } from '$env/dynamic/private';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
// Minimal chapter shape returned by /api/book-preview
export interface PreviewChapter {
number: number;
title: string;
url: string;
}
import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper';
export const load: PageServerLoad = async ({ params, locals }) => {
const { slug } = params;
@@ -39,40 +30,48 @@ export const load: PageServerLoad = async ({ params, locals }) => {
return {
book,
chapters,
previewChapters: null as PreviewChapter[] | null,
inLib: true,
saved,
lastChapter: progress?.chapter ?? null,
isAdmin: locals.user?.role === 'admin',
isLoggedIn: !!locals.user,
currentUserId: locals.user?.id ?? ''
currentUserId: locals.user?.id ?? '',
// Not scraping
scraping: false,
taskId: null as string | null
};
}
// Book not in PocketBase — try live preview from scraper
// Book not in PocketBase — ask backend to enqueue a scrape task.
try {
const res = await fetch(`${SCRAPER_URL}/api/book-preview/${encodeURIComponent(slug)}`);
const res = await backendFetch(`/api/book-preview/${encodeURIComponent(slug)}`);
if (res.status === 202) {
// Scrape task enqueued — show "scraping" placeholder page.
const body: { task_id: string; message: string } = await res.json();
log.info('books', 'scrape task enqueued for book', { slug, task_id: body.task_id });
return {
book: null,
chapters: [],
inLib: false,
saved: false,
lastChapter: null,
isAdmin: locals.user?.role === 'admin',
isLoggedIn: !!locals.user,
currentUserId: locals.user?.id ?? '',
scraping: true,
taskId: body.task_id
};
}
if (!res.ok) {
log.warn('books', 'book-preview returned error', { slug, status: res.status });
error(404, `Book "${slug}" not found`);
}
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: PreviewChapter[];
} = await res.json();
// Shape the meta into a Book-like object (no PocketBase id fields)
// 200 — book was already in library when backend checked
const preview: BookPreviewResponse = await res.json();
const previewBook = {
id: '',
slug: preview.meta.slug || slug,
@@ -90,14 +89,15 @@ export const load: PageServerLoad = async ({ params, locals }) => {
return {
book: previewBook,
chapters: [],
previewChapters: preview.chapters,
inLib: preview.in_lib,
chapters: preview.chapters,
inLib: true,
saved: false,
lastChapter: null,
isAdmin: locals.user?.role === 'admin',
isLoggedIn: !!locals.user,
currentUserId: locals.user?.id ?? ''
currentUserId: locals.user?.id ?? '',
scraping: false,
taskId: null as string | null
};
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;

View File

@@ -1,19 +1,27 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import CommentsSection from '$lib/components/CommentsSection.svelte';
let { data }: { data: PageData } = $props();
onMount(() => {
if (data.book?.slug) {
window.umami?.track('book_view', { slug: data.book.slug });
}
});
// ── Save / unsave ─────────────────────────────────────────────────────────
let saved = $state(data.saved);
let saved = $state(untrack(() => data.saved));
let saving = $state(false);
async function toggleSave() {
if (saving) return;
if (saving || !data.book) return;
saving = true;
try {
const method = saved ? 'DELETE' : 'POST';
const res = await fetch(`/api/library/${encodeURIComponent(data.book.slug)}`, { method });
const res = await fetch(`/api/library/${encodeURIComponent(data.book?.slug ?? '')}`, { method });
if (res.ok) saved = !saved;
} finally {
saving = false;
@@ -29,28 +37,24 @@
}
}
const genres = $derived(parseGenres(data.book.genres));
const genres = $derived(parseGenres(data.book?.genres ?? []));
// Use preview chapters if the book is not in the library (needed for chapter count)
const chapterList = $derived(
data.inLib
? data.chapters
: (data.previewChapters ?? [])
);
// Use chapters from loaded data (both library and preview paths return chapters now)
const chapterList = $derived(data.chapters ?? []);
// ── Admin: rescrape ───────────────────────────────────────────────────────
let scraping = $state(false);
let scrapeResult = $state<'queued' | 'busy' | 'error' | ''>('');
async function rescrape() {
if (scraping || !data.book.source_url) return;
if (scraping || !data.book?.source_url) return;
scraping = true;
scrapeResult = '';
try {
const res = await fetch('/api/scrape', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: data.book.source_url })
body: JSON.stringify({ url: data.book?.source_url })
});
if (res.ok) scrapeResult = 'queued';
else if (res.status === 409) scrapeResult = 'busy';
@@ -69,7 +73,7 @@
let rangeResult = $state<'queued' | 'busy' | 'error' | ''>('');
async function scrapeRange() {
if (rangeScraping || !data.book.source_url) return;
if (rangeScraping || !data.book?.source_url) return;
const from = parseInt(rangeFrom, 10);
const to = parseInt(rangeTo, 10);
if (!from || from < 1) return;
@@ -79,7 +83,7 @@
const res = await fetch('/api/scrape/range', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: data.book.source_url, from, to: to || undefined })
body: JSON.stringify({ url: data.book?.source_url, from, to: to || undefined })
});
if (res.ok) rangeResult = 'queued';
else if (res.status === 409) rangeResult = 'busy';
@@ -96,19 +100,68 @@
// ── 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>
<title>{data.book.title} — libnovel</title>
<title>{data.scraping ? 'Scraping…' : data.book?.title ?? 'Book'} — libnovel</title>
</svelte:head>
{#if data.scraping}
<!-- ═══════════════════════════════════════════ Scraping in progress ══ -->
<div class="flex flex-col items-center justify-center py-24 gap-5 text-center">
<svg class="w-10 h-10 text-amber-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
<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. This page will refresh automatically.
</p>
{#if data.taskId}
<p class="text-zinc-600 text-xs mt-2 font-mono">task: {data.taskId}</p>
{/if}
</div>
<a href="/" class="mt-2 text-sm text-amber-400 hover:text-amber-300 transition-colors">← Home</a>
</div>
{:else}
{@const book = data.book!}
<!-- ═══════════════════════════════════════════════════════════════ Hero ══ -->
<div class="relative rounded-xl overflow-hidden mb-8">
<!-- Blurred cover background -->
{#if data.book.cover}
{#if book.cover}
<div
class="absolute inset-0 bg-cover bg-center scale-110"
style="background-image: url('{data.book.cover}'); filter: blur(24px); opacity: 0.18;"
style="background-image: url('{book.cover}'); filter: blur(24px); opacity: 0.18;"
aria-hidden="true"
></div>
{/if}
@@ -118,10 +171,10 @@
<!-- Cover + meta row -->
<div class="flex gap-5 sm:gap-8">
<!-- Cover image -->
{#if data.book.cover}
{#if book.cover}
<img
src={data.book.cover}
alt={data.book.title}
src={book.cover}
alt={book.title}
class="w-28 sm:w-48 rounded-lg object-cover flex-shrink-0 border border-zinc-700 shadow-xl self-start"
/>
{/if}
@@ -130,7 +183,7 @@
<div class="flex flex-col gap-2 min-w-0 flex-1">
<!-- Title + "not in library" badge -->
<div class="flex items-start gap-2 flex-wrap">
<h1 class="text-xl sm:text-3xl font-bold text-zinc-100 leading-tight">{data.book.title}</h1>
<h1 class="text-xl sm:text-3xl font-bold text-zinc-100 leading-tight">{book.title}</h1>
{#if !data.inLib}
<span
class="mt-1 text-xs px-2 py-0.5 rounded-full bg-zinc-700 text-zinc-400 border border-zinc-600 shrink-0"
@@ -142,14 +195,14 @@
</div>
<!-- Author -->
{#if data.book.author}
<p class="text-zinc-400 text-sm">{data.book.author}</p>
{#if book.author}
<p class="text-zinc-400 text-sm">{book.author}</p>
{/if}
<!-- Status + genres -->
<div class="flex flex-wrap gap-1.5 mt-0.5">
{#if data.book.status}
<span class="text-xs px-2 py-0.5 rounded bg-zinc-700 text-zinc-300 border border-zinc-600">{data.book.status}</span>
{#if book.status}
<span class="text-xs px-2 py-0.5 rounded bg-zinc-700 text-zinc-300 border border-zinc-600">{book.status}</span>
{/if}
{#each genres as genre}
<span class="text-xs px-2 py-0.5 rounded bg-zinc-800 text-zinc-400 border border-zinc-700">{genre}</span>
@@ -157,12 +210,12 @@
</div>
<!-- Summary with expand toggle -->
{#if data.book.summary}
{#if book.summary}
<div class="mt-1">
<p class="text-zinc-400 text-sm leading-relaxed break-words {summaryExpanded ? '' : 'line-clamp-3'}">
{data.book.summary}
{book.summary}
</p>
{#if data.book.summary.length > 220}
{#if book.summary.length > 220}
<button
onclick={() => (summaryExpanded = !summaryExpanded)}
class="text-xs text-amber-400/70 hover:text-amber-400 mt-1 transition-colors"
@@ -177,7 +230,7 @@
<div class="hidden sm:flex gap-2 mt-3 items-center flex-wrap">
{#if data.lastChapter}
<a
href="/books/{data.book.slug}/chapters/{data.lastChapter}"
href="/books/{book.slug}/chapters/{data.lastChapter}"
class="px-5 py-2 bg-amber-400 text-zinc-900 font-semibold rounded-lg text-sm hover:bg-amber-300 transition-colors shadow"
>
Continue ch.{data.lastChapter}
@@ -185,7 +238,7 @@
{/if}
{#if chapterList.length > 0}
<a
href="/books/{data.book.slug}/chapters/1"
href="/books/{book.slug}/chapters/1"
class="px-4 py-2 rounded-lg text-sm font-semibold transition-colors
{data.lastChapter
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
@@ -228,7 +281,7 @@
<div class="flex sm:hidden gap-2 items-center">
{#if data.lastChapter}
<a
href="/books/{data.book.slug}/chapters/{data.lastChapter}"
href="/books/{book.slug}/chapters/{data.lastChapter}"
class="flex-1 text-center px-4 py-2.5 bg-amber-400 text-zinc-900 font-semibold rounded-lg text-sm hover:bg-amber-300 transition-colors shadow"
>
Continue ch.{data.lastChapter}
@@ -236,7 +289,7 @@
{/if}
{#if chapterList.length > 0}
<a
href="/books/{data.book.slug}/chapters/1"
href="/books/{book.slug}/chapters/1"
class="flex-1 text-center px-4 py-2.5 rounded-lg text-sm font-semibold transition-colors
{data.lastChapter
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600'
@@ -279,7 +332,7 @@
<div class="flex flex-col divide-y divide-zinc-800 border border-zinc-800 rounded-xl overflow-hidden mb-6">
<!-- Chapters row: links to the full chapter list page -->
<a
href="/books/{data.book.slug}/chapters"
href="/books/{book.slug}/chapters"
class="flex items-center gap-3 px-4 py-3.5 hover:bg-zinc-800/60 transition-colors group"
>
<svg class="w-4 h-4 text-amber-400 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
@@ -303,7 +356,7 @@
</a>
<!-- Admin panel (collapsed by default, admin only) -->
{#if data.isAdmin && data.book.source_url}
{#if data.isAdmin && book.source_url}
<div>
<button
onclick={() => (adminOpen = !adminOpen)}
@@ -396,4 +449,6 @@
</div>
<!-- ══════════════════════════════════════════════════ Comments ══ -->
<CommentsSection slug={data.book.slug} isLoggedIn={data.isLoggedIn} currentUserId={data.currentUserId} />
<CommentsSection slug={book.slug} isLoggedIn={data.isLoggedIn} currentUserId={data.currentUserId} />
{/if}

View File

@@ -2,11 +2,8 @@ import { error } from '@sveltejs/kit';
import { marked } from 'marked';
import type { PageServerLoad } from './$types';
import { getBook, listChapterIdx } from '$lib/server/pocketbase';
import { presignChapter } from '$lib/server/minio';
import { log } from '$lib/server/logger';
import { env } from '$env/dynamic/private';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
import { backendFetch } from '$lib/server/scraper';
export const load: PageServerLoad = async ({ params, url, locals }) => {
const { slug } = params;
@@ -26,8 +23,8 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
let chapterData: { slug: string; number: number; title: string; text: string; url: string };
try {
const res = await fetch(
`${SCRAPER_URL}/api/chapter-text-preview/${encodeURIComponent(slug)}/${n}?${previewParams.toString()}`
const res = await backendFetch(
`/api/chapter-text-preview/${encodeURIComponent(slug)}/${n}?${previewParams.toString()}`
);
if (!res.ok) {
log.error('chapter', 'chapter-text-preview returned error', { slug, n, status: res.status });
@@ -48,7 +45,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
// Fetch voices (non-critical for preview)
let voices: string[] = [];
try {
const vRes = await fetch(`${SCRAPER_URL}/api/voices`);
const vRes = await backendFetch('/api/voices');
if (vRes.ok) {
const d = (await vRes.json()) as { voices: string[] };
voices = d.voices ?? [];
@@ -88,7 +85,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
const [book, chapters, voicesRes] = await Promise.all([
getBook(slug),
listChapterIdx(slug),
fetch(`${SCRAPER_URL}/api/voices`).catch(() => null)
backendFetch('/api/voices').catch(() => null)
]);
if (!book) error(404, `Book "${slug}" not found`);
@@ -107,17 +104,21 @@ export const load: PageServerLoad = async ({ params, url, locals }) => {
// Non-critical — UI will use store default
}
// Get presigned URL and fetch chapter markdown server-side
// Fetch chapter markdown directly from the backend (server-side MinIO read)
let html = '';
try {
const presignUrl = await presignChapter(slug, n);
const res = await fetch(presignUrl);
if (!res.ok) throw new Error(`MinIO returned ${res.status}`);
const res = await backendFetch(`/api/chapter-markdown/${encodeURIComponent(slug)}/${n}`);
if (!res.ok) {
log.error('chapter', 'chapter-markdown returned error', { slug, n, status: res.status });
error(res.status === 404 ? 404 : 502, res.status === 404 ? `Chapter ${n} not found` : 'Could not fetch chapter content');
}
const markdown = await res.text();
html = marked(markdown) as string;
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
// Don't hard-fail — show empty content with error message
log.error('chapter', 'failed to fetch chapter content', { slug, n, err: String(e) });
error(502, 'Could not fetch chapter content');
}
const prevChapter = chapters.find((c) => c.number === n - 1) ?? null;

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, untrack } from 'svelte';
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let html = $state(data.html);
let fetchingContent = $state(!data.isPreview && !data.html);
let html = $state(untrack(() => data.html));
let fetchingContent = $state(untrack(() => !data.isPreview && !data.html));
let fetchError = $state('');
// ── Word count ────────────────────────────────────────────────────────────
@@ -19,6 +19,12 @@
const wordCount = $derived(countWords(html));
onMount(async () => {
// Umami analytics: track chapter reads
window.umami?.track('chapter_read', {
slug: data.book.slug,
chapter: data.chapter.number
});
// Record reading progress (skip for preview chapters)
if (!data.isPreview) {
try {

View File

@@ -1,170 +0,0 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
export interface NovelListing {
slug: string;
title: string;
cover: string;
rank: string;
rating: string;
chapters: string;
url: string;
// enriched fields (only set when sort=rank)
author?: string;
status?: string;
genres?: string[];
source_url?: string;
}
export const load: PageServerLoad = async ({ url, locals }) => {
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') ?? '';
let novels: NovelListing[] = [];
let pageNum = parseInt(page, 10) || 1;
let hasNext = false;
let searchQuery = '';
let searchLocalCount = 0;
let searchRemoteCount = 0;
// ── Search mode: ?q= overrides browse/ranking ─────────────────────────
if (q.trim().length >= 2) {
searchQuery = q.trim();
const apiURL = `${SCRAPER_URL}/api/search?q=${encodeURIComponent(searchQuery)}`;
try {
const res = await fetch(apiURL);
if (!res.ok) {
log.error('browse', 'search returned error', { status: res.status });
throw error(502, `Search failed: ${res.status}`);
}
const data: {
results: NovelListing[];
local_count: number;
remote_count: number;
} = await res.json();
novels = data.results ?? [];
searchLocalCount = data.local_count ?? 0;
searchRemoteCount = data.remote_count ?? 0;
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
log.error('browse', 'search network error', { q: searchQuery, err: String(e) });
throw error(502, 'Could not reach search service');
}
return {
novels,
page: 1,
hasNext: false,
genre,
sort,
status,
isAdmin: locals.user?.role === 'admin',
searchQuery,
searchLocalCount,
searchRemoteCount
};
}
if (sort === 'rank') {
// Ranking view: fetch from /api/ranking which returns richer metadata.
// Pagination and filters (genre/status) don't apply here — the ranking
// is a single pre-computed list from the last catalogue scrape.
const apiURL = `${SCRAPER_URL}/api/ranking`;
try {
const res = await fetch(apiURL);
if (!res.ok) {
log.error('browse', 'scraper ranking returned error', { status: res.status });
throw error(502, `Ranking fetch failed: ${res.status}`);
}
const items: Array<{
rank: number;
slug: string;
title: string;
author: string;
cover: string;
status: string;
genres: string[];
source_url: string;
}> = await res.json();
novels = (items ?? []).map((item) => ({
slug: item.slug,
title: item.title,
cover: item.cover,
rank: item.rank != null ? `#${item.rank}` : '',
rating: '',
chapters: '',
url: item.source_url ?? '',
author: item.author,
status: item.status,
genres: item.genres ?? [],
source_url: item.source_url
}));
pageNum = 1;
hasNext = false;
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
log.error('browse', 'scraper ranking network error', { err: String(e) });
throw error(502, 'Could not load ranking');
}
} else {
// Browse view: paginated catalogue from /api/browse.
const params = new URLSearchParams({ page, genre, sort, status });
const apiURL = `${SCRAPER_URL}/api/browse?${params.toString()}`;
try {
const res = await fetch(apiURL);
if (!res.ok) {
log.error('browse', 'scraper browse returned error', { status: res.status, url: apiURL });
throw error(502, `Browse fetch failed: ${res.status}`);
}
const data: { novels: NovelListing[]; page: number; hasNext: boolean } = await res.json();
novels = data.novels ?? [];
pageNum = data.page ?? 1;
hasNext = data.hasNext ?? false;
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
log.error('browse', 'scraper browse network error', { url: apiURL, err: String(e) });
throw error(502, 'Could not load browse page');
}
}
return {
novels,
page: pageNum,
hasNext,
genre,
sort,
status,
isAdmin: locals.user?.role === 'admin',
searchQuery: '',
searchLocalCount: 0,
searchRemoteCount: 0
};
};
// Admin action: trigger a full catalogue scrape (refreshes ranking + library).
export const actions: Actions = {
refresh: async ({ locals, fetch }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
try {
const res = await fetch('/api/scrape', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (res.status === 409) return { status: 'busy' };
if (!res.ok) return { status: 'error' };
return { status: 'queued' };
} catch {
return { status: 'error' };
}
}
};

View File

@@ -0,0 +1,90 @@
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 type { NovelListing };
export const load: PageServerLoad = async ({ url, locals }) => {
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());
}
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('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('catalogue', 'catalogue network error', { err: String(e) });
throw error(502, 'Could not reach catalogue service');
}
return {
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,
searchRemoteCount: 0
};
};
// Admin action: trigger a full catalogue scrape (refreshes ranking + library).
export const actions: Actions = {
refresh: async ({ locals, fetch }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
try {
const res = await fetch('/api/scrape', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (res.status === 409) return { status: 'busy' };
if (!res.ok) return { status: 'error' };
return { status: 'queued' };
} catch {
return { status: 'error' };
}
}
};

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { navigating } from '$app/state';
import { untrack } from 'svelte';
import type { PageData, ActionData } from './$types';
import type { NovelListing } from './+page.server';
@@ -21,9 +22,9 @@
// ── Infinite scroll state ────────────────────────────────────────────────
// novels is the accumulated list across all fetched pages.
// Seeded from SSR page 1; new pages are appended client-side.
let novels = $state<NovelListing[]>(data.novels);
let currentPage = $state(data.page);
let hasNext = $state(data.hasNext);
let novels = $state<NovelListing[]>(untrack(() => data.novels));
let currentPage = $state(untrack(() => data.page));
let hasNext = $state(untrack(() => data.hasNext));
let loadingMore = $state(false);
// A key derived from the active filters — when it changes, reset the list
@@ -53,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 ?? [])];
@@ -81,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.
@@ -172,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);
@@ -185,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(
@@ -206,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>"
@@ -250,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"
@@ -266,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
@@ -350,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}
@@ -381,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">
@@ -435,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

@@ -1,6 +1,7 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import { untrack } from 'svelte';
import type { PageData, ActionData } from './$types';
import { audioStore } from '$lib/audio.svelte';
import { browser } from '$app/environment';
@@ -8,7 +9,7 @@
let { data, form }: { data: PageData; form: ActionData } = $props();
// ── Avatar ───────────────────────────────────────────────────────────────────
let avatarUrl = $state<string | null>(data.avatarUrl ?? null);
let avatarUrl = $state<string | null>(untrack(() => data.avatarUrl ?? null));
let avatarUploading = $state(false);
let avatarError = $state('');
let fileInput: HTMLInputElement | null = null;
@@ -30,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';
@@ -152,7 +129,7 @@
is_current: boolean;
};
let sessions = $state<Session[]>(data.sessions ?? []);
let sessions = $state<Session[]>(untrack(() => data.sessions ?? []));
let revokingId = $state<string | null>(null);
let revokeError = $state('');

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// ── Subscribe / unsubscribe ──────────────────────────────────────────────────
let subscribed = $state(data.isSubscribed);
let followerCount = $state(data.profile.followerCount);
let subscribed = $state(untrack(() => data.isSubscribed));
let followerCount = $state(untrack(() => data.profile.followerCount));
let subLoading = $state(false);
async function toggleSubscribe() {