feat: add v2 stack (backend, runner, ui-v2) with release workflow
All checks were successful
Release / Scraper / Test (push) Successful in 10s
Release / UI / Build (push) Successful in 26s
Release / v2 / Build ui-v2 (push) Successful in 17s
Release / Scraper / Docker (push) Successful in 47s
Release / UI / Docker (push) Successful in 56s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / Scraper / Test (pull_request) Successful in 8s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / v2 / Docker / ui-v2 (push) Successful in 56s
Release / v2 / Test backend (push) Successful in 4m35s
iOS CI / Build (pull_request) Successful in 4m28s
Release / v2 / Docker / backend (push) Successful in 1m29s
Release / v2 / Docker / runner (push) Successful in 1m39s
iOS CI / Test (pull_request) Successful in 9m51s

- backend/: Go API server and runner binaries with PocketBase + MinIO storage
- ui-v2/: SvelteKit frontend rewrite
- docker-compose-new.yml: compose file for the v2 stack
- .gitea/workflows/release-v2.yaml: CI/CD for backend, runner, and ui-v2 Docker Hub images
- scripts/pb-init.sh: migrate from wget to curl, add superuser bootstrap for fresh installs
- .env.example: document DOCKER_BUILDKIT=1 for Colima users
This commit is contained in:
Admin
2026-03-15 19:32:40 +05:00
parent 1642434a79
commit 5825b859b7
142 changed files with 22768 additions and 26 deletions

5
ui-v2/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
build
.svelte-kit
.env
.env.*

20
ui-v2/.env.example Normal file
View File

@@ -0,0 +1,20 @@
# libnovel UI — environment variables
# Copy to .env and adjust; do NOT commit with real secrets.
# Public URL of the scraper API (used by SvelteKit server-side load functions)
# In docker-compose this is the internal service name
SCRAPER_API_URL=http://localhost:8080
# Public URL of PocketBase (used by SvelteKit server-side load functions)
POCKETBASE_URL=http://localhost:8090
# PocketBase admin credentials (server-side only, never exposed to browser)
POCKETBASE_ADMIN_EMAIL=admin@libnovel.local
POCKETBASE_ADMIN_PASSWORD=changeme123
# Public-facing MinIO URL (used to rewrite presigned URLs for the browser)
# In dev this is localhost; in prod set to your MinIO public domain
PUBLIC_MINIO_PUBLIC_URL=http://localhost:9000
# Secret used to sign auth tokens stored in cookies (generate with: openssl rand -hex 32)
AUTH_SECRET=change_this_to_a_long_random_secret

23
ui-v2/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
ui-v2/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

39
ui-v2/Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS builder
WORKDIR /app
# Install dependencies in a separate layer so it is cached as long as
# package-lock.json does not change. The npm cache mount persists the
# ~/.npm cache across builds so packages are not re-downloaded.
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
# Build-time version info — injected by docker-compose or CI via --build-arg.
ARG BUILD_VERSION=dev
ARG BUILD_COMMIT=unknown
# Expose as PUBLIC_ env vars so SvelteKit's $env/dynamic/public can read them.
ENV PUBLIC_BUILD_VERSION=$BUILD_VERSION
ENV PUBLIC_BUILD_COMMIT=$BUILD_COMMIT
RUN npm run build
# ── Runtime image ──────────────────────────────────────────────────────────────
# adapter-node bundles all server-side dependencies into build/ — no npm install
# needed at runtime. We do need package.json (for "type": "module") so Node
# resolves the ESM output correctly when there is no parent package.json.
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/package.json ./package.json
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
EXPOSE $PORT
CMD ["node", "build"]

42
ui-v2/README.md Normal file
View File

@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.12.4 create --template minimal --types ts --install npm ui
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

4160
ui-v2/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
ui-v2/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "ui",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^25.3.3",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1005.0",
"@aws-sdk/s3-request-presigner": "^3.1005.0",
"cropperjs": "^1.6.2",
"marked": "^17.0.3",
"pocketbase": "^0.26.8"
}
}

65
ui-v2/src/app.css Normal file
View File

@@ -0,0 +1,65 @@
@import "tailwindcss";
@theme {
--color-brand: #f59e0b; /* amber-400 */
--color-brand-dim: #d97706; /* amber-600 */
--color-surface: #18181b; /* zinc-900 */
--color-surface-2: #27272a; /* zinc-800 */
--color-surface-3: #3f3f46; /* zinc-700 */
--color-muted: #a1a1aa; /* zinc-400 */
--color-text: #f4f4f5; /* zinc-100 */
}
html {
background-color: var(--color-surface);
color: var(--color-text);
}
/* ── Chapter prose ─────────────────────────────────────────────────── */
.prose-chapter {
max-width: 72ch;
line-height: 1.85;
font-size: 1.05rem;
color: #d4d4d8; /* zinc-300 */
}
.prose-chapter h1,
.prose-chapter h2,
.prose-chapter h3 {
color: #f4f4f5;
font-weight: 700;
margin-top: 1.5em;
margin-bottom: 0.5em;
}
.prose-chapter h1 { font-size: 1.4rem; }
.prose-chapter h2 { font-size: 1.2rem; }
.prose-chapter h3 { font-size: 1.05rem; }
.prose-chapter p {
margin-bottom: 1.2em;
}
.prose-chapter em {
color: #a1a1aa;
}
.prose-chapter strong {
color: #f4f4f5;
}
.prose-chapter hr {
border-color: #3f3f46;
margin: 2em 0;
}
/* ── Navigation progress bar ───────────────────────────────────────── */
@keyframes progress-bar {
0% { width: 0%; opacity: 1; }
80% { width: 90%; opacity: 1; }
100% { width: 100%; opacity: 0; }
}
.animate-progress-bar {
animation: progress-bar 8s cubic-bezier(0.1, 0.05, 0.1, 1) forwards;
}

18
ui-v2/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
sessionId: string;
user: { id: string; username: string; role: string; authSessionId: string } | null;
}
interface PageData {
user?: { id: string; username: string; role: string; authSessionId: string } | null;
}
// interface PageState {}
// interface Platform {}
}
}
export {};

17
ui-v2/src/app.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32" />
<link rel="icon" type="image/png" href="/favicon-32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="/favicon-16.png" sizes="16x16" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" href="/icon-192.png" sizes="192x192" />
<link rel="icon" type="image/png" href="/icon-512.png" sizes="512x512" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

155
ui-v2/src/hooks.server.ts Normal file
View File

@@ -0,0 +1,155 @@
import type { Handle } from '@sveltejs/kit';
import { randomBytes, createHmac } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
import { createUserSession, touchUserSession, isSessionRevoked } from '$lib/server/pocketbase';
import { drain as drainPresignCache } from '$lib/server/presignCache';
// ─── 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';
const ONE_YEAR = 60 * 60 * 24 * 365;
const AUTH_SECRET = env.AUTH_SECRET ?? 'dev_secret_change_in_production';
// ─── Token helpers ────────────────────────────────────────────────────────────
/**
* Sign a payload string with HMAC-SHA256 using AUTH_SECRET.
* Returns "<payload>.<signature>".
*/
export function signToken(payload: string): string {
const sig = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex');
return `${payload}.${sig}`;
}
/**
* Verify a signed token. Returns the payload string on success, null on failure.
*/
export function verifyToken(token: string): string | null {
const lastDot = token.lastIndexOf('.');
if (lastDot < 0) return null;
const payload = token.slice(0, lastDot);
const expected = createHmac('sha256', AUTH_SECRET).update(payload).digest('hex');
const actual = token.slice(lastDot + 1);
// constant-time comparison
if (expected.length !== actual.length) return null;
let diff = 0;
for (let i = 0; i < expected.length; i++) {
diff |= expected.charCodeAt(i) ^ actual.charCodeAt(i);
}
return diff === 0 ? payload : null;
}
/**
* Create a signed auth token for a user.
* Payload format: "<userId>:<username>:<role>:<authSessionId>"
* authSessionId uniquely identifies this login session (for revocation).
*/
export function createAuthToken(userId: string, username: string, role: string, authSessionId: string): string {
return signToken(`${userId}:${username}:${role}:${authSessionId}`);
}
/**
* Parse a verified auth token into user data. Returns null if invalid.
* Supports both old format (3 segments) and new format (4 segments).
*/
export function parseAuthToken(token: string): { id: string; username: string; role: string; authSessionId: string } | null {
const payload = verifyToken(token);
if (!payload) return null;
const parts = payload.split(':');
// New format: userId:username:role:authSessionId (4 parts)
// Old format: userId:username:role (3 parts — legacy tokens before session tracking)
if (parts.length < 3) return null;
const id = parts[0];
const username = parts[1];
const role = parts[2];
const authSessionId = parts[3] ?? ''; // empty string for legacy tokens
if (!id || !username) return null;
return { id, username, role, authSessionId };
}
// ─── 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) {
sessionId = randomBytes(16).toString('hex');
event.cookies.set(SESSION_COOKIE, sessionId, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: ONE_YEAR
});
}
event.locals.sessionId = sessionId;
// Auth cookie → resolve logged-in user
const authToken = event.cookies.get(AUTH_COOKIE);
if (authToken) {
const user = parseAuthToken(authToken);
if (!user) {
log.warn('auth', 'auth cookie present but failed to parse (malformed or tampered)');
event.locals.user = null;
} else {
// Validate session against DB (only for new-format tokens with authSessionId)
let sessionValid = true;
if (user.authSessionId) {
try {
const revoked = await isSessionRevoked(user.authSessionId);
if (revoked) {
log.info('auth', 'auth cookie references revoked session', {
userId: user.id,
authSessionId: user.authSessionId
});
sessionValid = false;
// Clear the invalid cookie
event.cookies.delete(AUTH_COOKIE, { path: '/' });
} else {
// Best-effort: update last_seen in the background
touchUserSession(user.authSessionId).catch(() => {});
}
} catch (err) {
// DB error — fail open to avoid locking everyone out
log.warn('auth', 'session check failed (fail open)', { err: String(err) });
}
}
event.locals.user = sessionValid ? user : null;
}
} else {
event.locals.user = null;
}
return resolve(event);
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,146 @@
/**
* Global audio player state for libnovel.
*
* A single shared instance (module singleton) keeps audio playing across
* SvelteKit navigations. The layout mounts the <audio> element once and
* never unmounts it; the per-chapter AudioPlayer component is just a
* controller that reads/writes this state.
*
* Uses Svelte 5 runes ($state / $derived) — import only from .svelte files
* or other .svelte.ts files.
*
* ── State machine ────────────────────────────────────────────────────────────
*
* Current chapter (status):
* idle → loading → ready (fast path: audio exists in MinIO)
* idle → loading → generating → ready (slow path: Kokoro TTS)
* any → error
*
* Next chapter pre-fetch (nextStatus):
* 'none' no next chapter, or auto-next is off
* 'prefetching' POST /api/audio running for the next chapter
* 'prefetched' next chapter audio is ready in MinIO
* 'failed' pre-generation failed (will retry on navigate)
*
* Auto-next transition:
* onended fires → navigate to next chapter URL
* ↳ new chapter page mounts
* • if nextStatus === 'prefetched' → presign + play immediately
* • else → normal startPlayback() flow
*
* Pre-fetch is triggered when currentTime / duration >= 0.9 (90% mark).
* It only runs once per chapter (guarded by nextStatus !== 'none').
*/
export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed';
class AudioStore {
// ── What is loaded ──────────────────────────────────────────────────────
slug = $state('');
chapter = $state(0);
chapterTitle = $state('');
bookTitle = $state('');
voice = $state('af_bella');
speed = $state(1.0);
/** Cover image URL for the currently loaded book. */
cover = $state('');
/** Full chapter list for the currently loaded book (number + title). */
chapters = $state<{ number: number; title: string }[]>([]);
// ── Loading/generation state ────────────────────────────────────────────
status = $state<AudioStatus>('idle');
audioUrl = $state('');
errorMsg = $state('');
/** Pseudo-progress bar value 0100 during generation */
progress = $state(0);
// ── Playback state (kept in sync with the <audio> element) ─────────────
currentTime = $state(0);
duration = $state(0);
isPlaying = $state(false);
/**
* Increment to signal the layout to toggle play/pause.
* The layout watches this with $effect and calls audioEl.play()/pause().
*/
toggleRequest = $state(0);
/**
* Set to a number to seek the audio element to that time (seconds).
* The layout watches this with $effect and sets audioEl.currentTime.
* Reset to null after handling.
*/
seekRequest = $state<number | null>(null);
// ── Auto-next ────────────────────────────────────────────────────────────
/**
* When true, navigates to the next chapter when the current one ends
* and auto-starts its audio.
*/
autoNext = $state(false);
/**
* The next chapter number for the currently playing chapter, or null if
* there is no next chapter. Written by the chapter page's AudioPlayer.
* Stored here (not cleared on unmount) so onended can still read it after
* the component unmounts due to {#key} re-render on navigation.
*/
nextChapter = $state<number | null>(null);
/**
* Set to the chapter number that should auto-start by the layout's onended
* handler (when autoNext fires a navigation). The AudioPlayer on the new
* page checks this on mount: if it matches the component's own chapter prop
* it starts playback and clears the value.
*
* Using the target chapter number (instead of a plain boolean) prevents the
* still-mounted outgoing AudioPlayer from reacting to the flag before the
* navigation completes — it only matches the incoming chapter's component.
*/
autoStartChapter = $state<number | null>(null);
// ── Next-chapter pre-fetch state ─────────────────────────────────────────
/**
* State of the background pre-generation for the next chapter.
* 'none' nothing started (default / no next chapter)
* 'prefetching' currently running POST /api/audio for next chapter
* 'prefetched' next chapter audio confirmed ready in MinIO
* 'failed' pre-generation failed (fallback: generate on navigate)
*/
nextStatus = $state<NextStatus>('none');
/**
* The presigned URL obtained during pre-fetch. When the user navigates
* to the next chapter, AudioPlayer picks this up and skips straight to play.
*/
nextAudioUrl = $state('');
/** Progress value (0100) shown while pre-generating the next chapter. */
nextProgress = $state(0);
/** Which chapter number the pre-fetch state above belongs to. */
nextChapterPrefetched = $state<number | null>(null);
/** Whether the mini-bar at the bottom is visible */
get active(): boolean {
return this.status === 'ready' || this.status === 'generating' || this.status === 'loading';
}
/** True when the currently loaded track matches slug+chapter */
isCurrentChapter(slug: string, chapter: number): boolean {
return this.slug === slug && this.chapter === chapter;
}
/** Reset all next-chapter pre-fetch state. */
resetNextPrefetch() {
this.nextStatus = 'none';
this.nextAudioUrl = '';
this.nextProgress = 0;
this.nextChapterPrefetched = null;
}
}
export const audioStore = new AudioStore();

View File

@@ -0,0 +1,862 @@
<script lang="ts">
/**
* AudioPlayer — controller component.
*
* Does NOT own an <audio> element. Instead it reads/writes `audioStore`,
* which is shared with the layout's persistent <audio> element so audio
* survives SvelteKit navigations.
*
* ── Play flow ────────────────────────────────────────────────────────────
* On "Play narration" click / auto-start:
* 1. Populate store metadata (slug, chapter, titles, voice, speed).
* 2. If the pre-fetch already landed (nextStatus='prefetched' AND
* 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 (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.
* Each voice shows a play button that streams a pre-generated sample from
* MinIO (GET /api/presign/voice-sample?voice=...). Samples are generated
* server-side via POST /api/audio/voice-samples.
*
* Changing voice updates audioStore.voice (saved to settings via layout).
* The currently loaded chapter audio is NOT re-generated automatically —
* the new voice takes effect on next "Play narration" click.
*
* ── Pre-fetch (immediate + 90% fallback) ────────────────────────────────
* When autoNext is on, prefetchNext() is called as soon as the current
* chapter starts playing (via maybeStartPrefetch() at the end of
* startPlayback()). This gives the maximum lead time for Kokoro to
* generate the next chapter so the transition is seamless.
*
* A $effect also watches currentTime/duration and fires prefetchNext() at
* the 90% mark as a fallback — covering the case where autoNext was toggled
* on mid-playback after startPlayback() had already returned.
* The nextStatus !== 'none' guard prevents double-runs in all cases.
*
* prefetchNext():
* • Calls POST /api/audio for next chapter (sets nextStatus='prefetching')
* • On success, presigns and stores URL in audioStore.nextAudioUrl
* (sets nextStatus='prefetched')
* • On failure, sets nextStatus='failed'
*
* ── Auto-next ────────────────────────────────────────────────────────────
* layout.svelte onended → sets autoStartPending=true → navigates.
* New chapter's AudioPlayer mounts → sees autoStartPending → startPlayback()
* which uses the prefetched URL if available.
*/
import { audioStore } from '$lib/audio.svelte';
interface Props {
slug: string;
chapter: number;
chapterTitle?: string;
bookTitle?: string;
/** Cover image URL for the book (used in MediaSession for lock-screen art). */
cover?: string;
/** Next chapter number, or null/undefined if this is the last chapter. */
nextChapter?: number | null;
/** Full chapter list for the book (number + title). Written into the store. */
chapters?: { number: number; title: string }[];
/** List of available voices from the Kokoro API. */
voices?: string[];
}
let {
slug,
chapter,
chapterTitle = '',
bookTitle = '',
cover = '',
nextChapter = null,
chapters = [],
voices = []
}: Props = $props();
// ── Voice selector state ────────────────────────────────────────────────
let showVoicePanel = $state(false);
/** Voice whose sample is currently being fetched or playing. */
let samplePlayingVoice = $state<string | null>(null);
/** Currently active sample <audio> element — one at a time. */
let sampleAudio = $state<HTMLAudioElement | null>(null);
/**
* Human-readable label for a voice ID.
* e.g. "af_bella" → "Bella (US F)" | "bm_george" → "George (UK M)"
*/
function voiceLabel(v: string): string {
const langMap: Record<string, string> = {
af: 'US', am: 'US',
bf: 'UK', bm: 'UK',
ef: 'ES', em: 'ES',
ff: 'FR',
hf: 'IN', hm: 'IN',
'if': 'IT', im: 'IT',
jf: 'JP', jm: 'JP',
pf: 'PT', pm: 'PT',
zf: 'ZH', zm: 'ZH',
};
const genderMap: Record<string, string> = {
af: 'F', am: 'M',
bf: 'F', bm: 'M',
ef: 'F', em: 'M',
ff: 'F',
hf: 'F', hm: 'M',
'if': 'F', im: 'M',
jf: 'F', jm: 'M',
pf: 'F', pm: 'M',
zf: 'F', zm: 'M',
};
const prefix = v.slice(0, 2);
const name = v.slice(3);
// Capitalise and strip legacy v0 prefix.
const displayName = name
.replace(/^v0/, '')
.replace(/^([a-z])/, (c: string) => c.toUpperCase());
const lang = langMap[prefix] ?? prefix.toUpperCase();
const gender = genderMap[prefix] ?? '?';
return `${displayName} (${lang} ${gender})`;
}
/** Stop any currently playing sample. */
function stopSample() {
if (sampleAudio) {
sampleAudio.pause();
sampleAudio.src = '';
sampleAudio = null;
}
samplePlayingVoice = null;
}
/** Play a voice sample from MinIO. */
async function playSample(voice: string) {
// If this voice is already playing, stop it.
if (samplePlayingVoice === voice) {
stopSample();
return;
}
stopSample();
samplePlayingVoice = voice;
try {
const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voice)}`);
if (res.status === 404) {
// Sample not generated yet — silently ignore
samplePlayingVoice = null;
return;
}
if (!res.ok) throw new Error(`presign failed: ${res.status}`);
const data = (await res.json()) as { url: string };
const audio = new Audio(data.url);
sampleAudio = audio;
audio.onended = () => {
if (samplePlayingVoice === voice) stopSample();
};
audio.onerror = () => {
if (samplePlayingVoice === voice) stopSample();
};
await audio.play();
} catch {
samplePlayingVoice = null;
}
}
/** Select a voice and close the panel. */
function selectVoice(voice: string) {
stopSample();
audioStore.voice = voice;
showVoicePanel = false;
}
// Keep nextChapter in the store so the layout's onended can navigate.
// NOTE: we do NOT clear on unmount here — the store retains the value so
// onended (which may fire after {#key} unmounts this component) can still
// read it. The value is superseded when the new chapter mounts.
$effect(() => {
audioStore.nextChapter = nextChapter ?? null;
});
// Auto-start: if the layout navigated here via auto-next, kick off playback.
// We match against the chapter prop so the outgoing chapter's AudioPlayer
// (still mounted during the brief navigation window) never reacts to this.
$effect(() => {
if (audioStore.autoStartChapter === chapter) {
audioStore.autoStartChapter = null;
startPlayback();
}
});
// Reset next-chapter prefetch state when this chapter changes (new page).
// Only reset if the prefetch belongs to neither the current chapter
// (about to be consumed by startPlayback) nor the next chapter (still valid).
// Any other value means stale data from a previous page.
$effect(() => {
const prefetchedFor = audioStore.nextChapterPrefetched;
if (
prefetchedFor !== null &&
prefetchedFor !== chapter &&
prefetchedFor !== (nextChapter ?? null)
) {
audioStore.resetNextPrefetch();
}
});
// Close voice panel when user clicks outside (escape key).
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
stopSample();
showVoicePanel = false;
}
}
// ── 90% pre-fetch trigger ─────────────────────────────────────────────────
// Watch playback progress; when >= 90% of current chapter, pre-generate
// the next chapter's audio so it's ready when we navigate.
$effect(() => {
const ct = audioStore.currentTime;
const dur = audioStore.duration;
const isCurrentlyPlaying = audioStore.isCurrentChapter(slug, chapter);
if (
!isCurrentlyPlaying ||
!audioStore.autoNext ||
nextChapter === null ||
nextChapter === undefined ||
dur <= 0 ||
ct / dur < 0.9 ||
audioStore.nextStatus !== 'none'
) {
return;
}
// Trigger exactly once (nextStatus transitions away from 'none')
prefetchNext();
});
// ── Pseudo progress helpers ────────────────────────────────────────────────
let progressRafId = 0;
function startProgress() {
audioStore.progress = 0;
let last = performance.now();
function tick(now: number) {
const dt = (now - last) / 1000;
last = now;
let rate: number;
if (audioStore.progress < 30) rate = 4;
else if (audioStore.progress < 60) rate = 12;
else if (audioStore.progress < 80) rate = 4;
else rate = 0.3;
audioStore.progress = Math.min(audioStore.progress + rate * dt, 99);
if (audioStore.progress < 99) {
progressRafId = requestAnimationFrame(tick);
}
}
progressRafId = requestAnimationFrame(tick);
}
function stopProgress() {
if (progressRafId) {
cancelAnimationFrame(progressRafId);
progressRafId = 0;
}
}
async function finishProgress() {
stopProgress();
const step = () => {
audioStore.progress = Math.min(audioStore.progress + 8, 100);
if (audioStore.progress < 100) {
progressRafId = requestAnimationFrame(step);
}
};
progressRafId = requestAnimationFrame(step);
await new Promise((r) => setTimeout(r, 200));
stopProgress();
}
// ── Next-chapter pseudo-progress helpers ──────────────────────────────────
let nextProgressRafId = 0;
function startNextProgress() {
audioStore.nextProgress = 0;
let last = performance.now();
function tick(now: number) {
const dt = (now - last) / 1000;
last = now;
let rate: number;
if (audioStore.nextProgress < 30) rate = 4;
else if (audioStore.nextProgress < 60) rate = 12;
else if (audioStore.nextProgress < 80) rate = 4;
else rate = 0.3;
audioStore.nextProgress = Math.min(audioStore.nextProgress + rate * dt, 99);
if (audioStore.nextProgress < 99) {
nextProgressRafId = requestAnimationFrame(tick);
}
}
nextProgressRafId = requestAnimationFrame(tick);
}
function stopNextProgress() {
if (nextProgressRafId) {
cancelAnimationFrame(nextProgressRafId);
nextProgressRafId = 0;
}
}
// ── API helpers ────────────────────────────────────────────────────────────
async function tryPresign(
targetSlug: string,
targetChapter: number,
targetVoice: string
): Promise<string | null> {
const params = new URLSearchParams({
slug: targetSlug,
n: String(targetChapter),
voice: targetVoice
});
const res = await fetch(`/api/presign/audio?${params}`);
if (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' }
| { status: 'pending' | 'generating'; job_id: string }
| { status: 'idle' }
| { status: 'failed'; error?: string };
/**
* Poll GET /api/audio/status/[slug]/[n]?voice=... every `intervalMs` ms
* until status is "done" or "failed" (or the caller cancels via signal).
*
* Returns the final status response, or throws on network error / cancellation.
*/
async function pollAudioStatus(
targetSlug: string,
targetChapter: number,
targetVoice: string,
intervalMs = 2000,
signal?: AbortSignal
): Promise<AudioStatusResponse> {
const qs = new URLSearchParams();
if (targetVoice) qs.set('voice', targetVoice);
const url = `/api/audio/status/${targetSlug}/${targetChapter}?${qs.toString()}`;
while (true) {
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`Status poll HTTP ${res.status}`);
const data = (await res.json()) as AudioStatusResponse;
if (data.status === 'done' || data.status === 'failed') {
return data;
}
// Still pending/generating — wait then retry.
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, intervalMs);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
});
});
}
}
// ── Pre-fetch next chapter ─────────────────────────────────────────────────
async function prefetchNext() {
if (nextChapter === null || nextChapter === undefined) return;
if (audioStore.nextStatus !== 'none') return; // already running or done
const voice = audioStore.voice;
audioStore.nextStatus = 'prefetching';
audioStore.nextChapterPrefetched = nextChapter;
startNextProgress();
try {
// Fast path: already generated
const url = await tryPresign(slug, nextChapter, voice);
if (url) {
stopNextProgress();
audioStore.nextProgress = 100;
audioStore.nextAudioUrl = url;
audioStore.nextStatus = 'prefetched';
return;
}
// Slow path: trigger Kokoro generation (non-blocking POST), then poll.
const res = await fetch(`/api/audio/${slug}/${nextChapter}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice })
});
if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`);
// 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) {
// 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;
}
// 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';
} catch {
stopNextProgress();
audioStore.nextStatus = 'failed';
}
}
// ── Media Session ──────────────────────────────────────────────────────────
// Sets the OS-level media metadata so the book cover, title, and chapter
// appear on the phone lock screen / notification center.
function setMediaSession() {
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
const artwork: MediaImage[] = cover
? [
{ src: cover, sizes: '512x512', type: 'image/jpeg' },
{ src: cover, sizes: '256x256', type: 'image/jpeg' }
]
: [];
navigator.mediaSession.metadata = new MediaMetadata({
title: chapterTitle || `Chapter ${chapter}`,
artist: bookTitle,
album: bookTitle,
artwork
});
}
// ── Core play flow ─────────────────────────────────────────────────────────
async function startPlayback() {
const voice = audioStore.voice;
// Populate store metadata so layout + mini-bar have track info.
audioStore.slug = slug;
audioStore.chapter = chapter;
audioStore.chapterTitle = chapterTitle;
audioStore.bookTitle = bookTitle;
audioStore.cover = cover;
audioStore.chapters = chapters;
// Update OS media session (lock screen / notification center).
setMediaSession();
audioStore.status = 'loading';
audioStore.errorMsg = '';
try {
// Fast path A: pre-fetch already landed for THIS chapter.
if (
audioStore.nextStatus === 'prefetched' &&
audioStore.nextChapterPrefetched === chapter &&
audioStore.nextAudioUrl
) {
const url = audioStore.nextAudioUrl;
// Consume the pre-fetch — reset so it doesn't carry over
audioStore.resetNextPrefetch();
audioStore.audioUrl = url;
audioStore.status = 'ready';
// Don't restore saved time for auto-next; position is 0
// Immediately start pre-generating the chapter after this one.
maybeStartPrefetch();
return;
}
// Fast path B: audio already in MinIO (presign check).
const url = await tryPresign(slug, chapter, voice);
if (url) {
audioStore.audioUrl = url;
audioStore.status = 'ready';
// Restore last saved position after the audio element loads
restoreSavedAudioTime();
// Immediately start pre-generating the next chapter in background.
maybeStartPrefetch();
return;
}
// Slow path: trigger Kokoro generation (non-blocking POST), then poll.
audioStore.status = 'generating';
startProgress();
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice })
});
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
if (res.status !== 200) {
// 202: generation enqueued — 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'}`
);
}
} else {
// 200: already cached — body is { status: 'done' }, no url needed.
await res.body?.cancel();
}
await finishProgress();
// 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
// Immediately start pre-generating the next chapter in background.
maybeStartPrefetch();
} catch (e) {
stopProgress();
audioStore.progress = 0;
audioStore.status = 'error';
audioStore.errorMsg = String(e);
}
}
/**
* Start pre-fetching the next chapter if autoNext is on, there is a next
* chapter, and no prefetch is already running or completed.
* Called as soon as current-chapter playback begins so that the next
* chapter's audio is ready before we need it (seamless transition).
* The 90%-mark $effect acts as a fallback for cases where autoNext is
* toggled on mid-playback.
*/
function maybeStartPrefetch() {
if (
audioStore.autoNext &&
nextChapter !== null &&
nextChapter !== undefined &&
audioStore.nextStatus === 'none'
) {
prefetchNext();
}
}
/**
* Fetch the saved audio time for this chapter and seek to it after a short
* delay (to allow the audio element to load the source).
*/
async function restoreSavedAudioTime() {
try {
const params = new URLSearchParams({ slug, chapter: String(chapter) });
const res = await fetch(`/api/progress/audio-time?${params}`);
if (!res.ok) return;
const data = (await res.json()) as { audioTime: number | null };
if (data.audioTime && data.audioTime > 5) {
// Small delay to let the <audio> element fully load the src before seeking
setTimeout(() => {
audioStore.seekRequest = data.audioTime as number;
}, 300);
}
} catch {
// Non-critical — silently ignore
}
}
async function handlePlay() {
const isCurrent = audioStore.isCurrentChapter(slug, chapter);
// Already loaded this chapter: toggle play/pause.
if (isCurrent && audioStore.status === 'ready') {
audioStore.toggleRequest = (audioStore.toggleRequest ?? 0) + 1;
return;
}
// Not yet loaded — start the full flow.
await startPlayback();
}
function formatTime(s: number): string {
if (!isFinite(s) || s < 0) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
</script>
<svelte:window onkeydown={handleKeyDown} />
<div class="mt-6 p-4 rounded-lg bg-zinc-800 border border-zinc-700">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
</svg>
<span class="text-sm text-zinc-300 font-medium">Audio Narration</span>
</div>
<!-- Voice selector button -->
{#if voices.length > 0}
<button
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'}"
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">
<path d="M7 10l5 5 5-5z"/>
</svg>
</button>
{/if}
</div>
<!-- ── Voice selector panel ──────────────────────────────────────────── -->
{#if showVoicePanel && voices.length > 0}
<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
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>
</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' : ''}"
role="button"
tabindex="0"
onclick={() => selectVoice(v)}
onkeydown={(e) => e.key === 'Enter' && selectVoice(v)}
>
<!-- Selected indicator -->
<div class="w-4 flex-shrink-0">
{#if audioStore.voice === v}
<svg class="w-3.5 h-3.5 text-amber-400" 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>
{/if}
</div>
<!-- Voice name -->
<span class="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
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>
</div>
{/each}
</div>
<div class="px-3 py-2 border-t border-zinc-700 bg-zinc-800/50">
<p class="text-xs text-zinc-500">
New voice applies on next "Play narration".
{#if voices.length > 0}
<a
href="/api/audio/voice-samples"
class="text-zinc-400 hover:text-amber-400 transition-colors underline"
onclick={(e) => {
e.preventDefault();
fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
}}
>Generate missing samples</a>
{/if}
</p>
</div>
</div>
{/if}
{#if audioStore.isCurrentChapter(slug, chapter)}
<!-- ── 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">
<path d="M8 5v14l11-7z"/>
</svg>
Play narration
</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"
>
<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>
{:else if audioStore.status === 'generating'}
<div class="space-y-2">
<p class="text-xs text-zinc-400">Generating narration…</p>
<div class="w-full h-1.5 bg-zinc-700 rounded-full overflow-hidden">
<div
class="h-full bg-amber-400 rounded-full transition-none"
style="width: {audioStore.progress}%"
></div>
</div>
<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>
{/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>
{/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 ── -->
<div class="flex items-center justify-between gap-3">
<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"
>
Load this chapter
</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">
<path d="M8 5v14l11-7z"/>
</svg>
Play narration
</button>
{/if}
</div>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import Cropper from 'cropperjs';
import type { default as CropperType } from 'cropperjs';
import 'cropperjs/dist/cropper.css';
interface Props {
file: File;
onconfirm: (blob: Blob, mimeType: string) => void;
oncancel: () => void;
}
let { file, onconfirm, oncancel }: Props = $props();
let imgEl: HTMLImageElement | undefined = $state();
let cropper: CropperType | null = null;
let objectUrl = '';
// 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).
$effect(() => {
if (!imgEl || !file) return;
// Create the object URL and set src directly on the element (not via reactive
// state) so cropperjs sees the correct src before the image load event fires.
objectUrl = URL.createObjectURL(file);
imgEl.src = objectUrl;
// Cropperjs must be initialised inside the image's load event so it can
// measure the natural dimensions — if we call new Cropper() before the image
// has loaded, the crop canvas is blank/invisible.
const handleLoad = () => {
cropper = new Cropper(imgEl!, {
aspectRatio: 1,
viewMode: 1,
dragMode: 'move',
autoCropArea: 0.8,
restore: false,
guides: false,
center: true,
highlight: false,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
background: false
});
};
imgEl.addEventListener('load', handleLoad, { once: true });
return () => {
imgEl?.removeEventListener('load', handleLoad);
cropper?.destroy();
cropper = null;
URL.revokeObjectURL(objectUrl);
objectUrl = '';
};
});
onDestroy(() => {
cropper?.destroy();
if (objectUrl) URL.revokeObjectURL(objectUrl);
});
function confirm() {
if (!cropper) return;
const canvas = cropper.getCroppedCanvas({ width: 400, height: 400 });
const mimeType = file.type === 'image/webp' ? 'image/webp' : 'image/jpeg';
canvas.toBlob(
(blob: Blob | null) => {
if (blob) onconfirm(blob, mimeType);
},
mimeType,
0.9
);
}
</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>
<!-- 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="rounded-xl bg-zinc-800" style="height: 300px; position: relative;">
<img
bind:this={imgEl}
alt="Crop preview"
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>
</div>
</div>

View File

@@ -0,0 +1,560 @@
<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[];
}
let {
slug,
isLoggedIn = false,
currentUserId = ''
}: {
slug: string;
isLoggedIn?: boolean;
currentUserId?: string;
} = $props();
// ── State ─────────────────────────────────────────────────────────────────
let comments = $state<BookComment[]>([]);
let myVotes = $state<Record<string, 'up' | 'down'>>({});
let avatarUrls = $state<Record<string, string>>({});
let loading = $state(true);
let loadError = $state('');
// Top-level new comment
let newBody = $state('');
let posting = $state(false);
let postError = $state('');
// Sort
let sort = $state<'new' | 'top'>('top');
// Reply state: which comment is being replied to
let replyingTo = $state<string | null>(null); // comment id
let replyBody = $state('');
let replyPosting = $state(false);
let replyError = $state('');
// Delete in-flight set
let deletingIds = $state(new Set<string>());
// Per-comment vote inflight set (prevents double-clicks)
let votingIds = $state(new Set<string>());
// ── Load comments ─────────────────────────────────────────────────────────
async function loadComments() {
loading = true;
loadError = '';
try {
const res = await fetch(
`/api/comments/${encodeURIComponent(slug)}?sort=${sort}`
);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
comments = data.comments ?? [];
myVotes = data.myVotes ?? {};
avatarUrls = data.avatarUrls ?? {};
} catch (e) {
loadError = 'Failed to load comments.';
} finally {
loading = false;
}
}
$effect(() => {
loadComments();
});
// Re-load when sort changes (after initial mount)
let firstLoad = true;
$effect(() => {
// Read sort to create a dependency
const _ = sort;
if (firstLoad) { firstLoad = false; return; }
loadComments();
});
// ── Post top-level comment ────────────────────────────────────────────────
async function postComment() {
const text = newBody.trim();
if (!text || posting) return;
if (text.length > 2000) { postError = 'Comment is too long (max 2000 characters).'; return; }
posting = true;
postError = '';
try {
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: text })
});
if (res.status === 401) { postError = 'You must be logged in to comment.'; return; }
if (!res.ok) {
const err = await res.json().catch(() => ({}));
postError = err.message ?? 'Failed to post comment.';
return;
}
const created: BookComment = await res.json();
created.replies = [];
// Prepend for 'new', or re-sort for 'top'
if (sort === 'new') {
comments = [created, ...comments];
} else {
comments = [created, ...comments]; // new comment has 0 score, goes to end after sort would happen
}
newBody = '';
} catch {
postError = 'Failed to post comment.';
} finally {
posting = false;
}
}
// ── Post reply ────────────────────────────────────────────────────────────
async function postReply(parentId: string) {
const text = replyBody.trim();
if (!text || replyPosting) return;
if (text.length > 2000) { replyError = 'Reply is too long (max 2000 characters).'; return; }
replyPosting = true;
replyError = '';
try {
const res = await fetch(`/api/comments/${encodeURIComponent(slug)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: text, parent_id: parentId })
});
if (res.status === 401) { replyError = 'You must be logged in to reply.'; return; }
if (!res.ok) {
const err = await res.json().catch(() => ({}));
replyError = err.message ?? 'Failed to post reply.';
return;
}
const created: BookComment = await res.json();
// Append to the parent's replies list
comments = comments.map((c) => {
if (c.id !== parentId) return c;
return { ...c, replies: [...(c.replies ?? []), created] };
});
replyBody = '';
replyingTo = null;
} catch {
replyError = 'Failed to post reply.';
} finally {
replyPosting = false;
}
}
// ── Delete ────────────────────────────────────────────────────────────────
async function deleteComment(commentId: string, parentId?: string) {
if (deletingIds.has(commentId)) return;
deletingIds = new Set([...deletingIds, commentId]);
try {
const res = await fetch(`/api/comment/${commentId}`, { method: 'DELETE' });
if (!res.ok) return;
if (parentId) {
// Remove reply from parent
comments = comments.map((c) => {
if (c.id !== parentId) return c;
return { ...c, replies: (c.replies ?? []).filter((r) => r.id !== commentId) };
});
} else {
// Remove top-level comment
comments = comments.filter((c) => c.id !== commentId);
}
} finally {
const next = new Set(deletingIds);
next.delete(commentId);
deletingIds = next;
}
}
// ── Vote ──────────────────────────────────────────────────────────────────
async function vote(commentId: string, v: 'up' | 'down', parentId?: string) {
if (votingIds.has(commentId)) return;
votingIds = new Set([...votingIds, commentId]);
try {
const res = await fetch(`/api/comment/${commentId}/vote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vote: v })
});
if (!res.ok) return;
const updated: BookComment = await res.json();
// Update comment in list (handle both top-level and replies)
if (parentId) {
comments = comments.map((c) => {
if (c.id !== parentId) return c;
return {
...c,
replies: (c.replies ?? []).map((r) => (r.id === commentId ? updated : r))
};
});
} else {
comments = comments.map((c) => (c.id === commentId ? { ...updated, replies: c.replies } : c));
}
// Update myVotes: toggle off if same, else set new vote
const prev = myVotes[commentId];
if (prev === v) {
const next = { ...myVotes };
delete next[commentId];
myVotes = next;
} else {
myVotes = { ...myVotes, [commentId]: v };
}
} finally {
const next = new Set(votingIds);
next.delete(commentId);
votingIds = next;
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
function initials(username: string): string {
const name = username.trim() || '?';
return name.slice(0, 2).toUpperCase();
}
function formatDate(iso: string): string {
try {
const date = new Date(iso);
const now = Date.now();
const diffMs = now - date.getTime();
const diffMins = Math.floor(diffMs / 60_000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays}d ago`;
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
} catch {
return iso;
}
}
const charCount = $derived(newBody.length);
const charOver = $derived(charCount > 2000);
const replyCharCount = $derived(replyBody.length);
const replyCharOver = $derived(replyCharCount > 2000);
const totalCount = $derived(
comments.reduce((n, c) => n + 1 + (c.replies?.length ?? 0), 0)
);
</script>
<div class="mt-10">
<!-- Header + sort controls -->
<div class="flex items-center justify-between gap-3 mb-4 flex-wrap">
<h2 class="text-base font-semibold text-zinc-200">
Comments
{#if !loading && totalCount > 0}
<span class="text-zinc-500 font-normal text-sm ml-1">({totalCount})</span>
{/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}
</div>
<!-- Post form -->
<div class="mb-6">
{#if isLoggedIn}
<div class="flex flex-col gap-2">
<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>
<div class="flex items-center justify-between gap-3">
<span class="text-xs {charOver ? 'text-red-400' : 'text-zinc-600'} tabular-nums">
{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}
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'}"
>
{posting ? 'Posting…' : 'Post'}
</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>
to leave a comment.
</p>
{/if}
</div>
<!-- Comment list -->
{#if loading}
<div class="flex flex-col gap-3">
{#each Array(3) as _}
<div class="rounded-lg bg-zinc-800/50 p-4 animate-pulse">
<div class="h-3 w-24 bg-zinc-700 rounded mb-3"></div>
<div class="h-3 w-full bg-zinc-700/60 rounded mb-2"></div>
<div class="h-3 w-3/4 bg-zinc-700/60 rounded"></div>
</div>
{/each}
</div>
{:else if loadError}
<p class="text-sm text-red-400">{loadError}</p>
{:else if comments.length === 0}
<p class="text-sm text-zinc-500">No comments yet. Be the first!</p>
{:else}
<div class="flex flex-col gap-3">
{#each comments as comment (comment.id)}
{@const myVote = myVotes[comment.id]}
{@const voting = votingIds.has(comment.id)}
{@const deleting = deletingIds.has(comment.id)}
{@const isOwner = isLoggedIn && currentUserId === comment.user_id}
<div class="rounded-lg bg-zinc-800/50 border border-zinc-700/50 px-4 py-3 flex flex-col gap-2 {deleting ? 'opacity-50' : ''}">
<!-- Header -->
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[comment.user_id]}
<img src={avatarUrls[comment.user_id]} alt={comment.username} class="w-6 h-6 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-6 h-6 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
<span class="text-[9px] font-semibold text-zinc-300 leading-none">{initials(comment.username)}</span>
</div>
{/if}
{#if comment.username}
<a href="/users/{comment.username}" class="text-sm font-medium text-zinc-200 hover:text-amber-400 transition-colors">{comment.username}</a>
{:else}
<span class="text-sm font-medium text-zinc-400">Anonymous</span>
{/if}
<span class="text-zinc-600 text-xs">&middot;</span>
<span class="text-xs text-zinc-500">{formatDate(comment.created)}</span>
</div>
<!-- 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'}"
>
<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
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'}"
>
<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
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>
</div>
</div>
{/if}
<!-- Replies -->
{#if comment.replies && comment.replies.length > 0}
<div class="mt-1 flex flex-col gap-2 pl-3 border-l-2 border-zinc-700/60">
{#each comment.replies as reply (reply.id)}
{@const replyVote = myVotes[reply.id]}
{@const replyVoting = votingIds.has(reply.id)}
{@const replyDeleting = deletingIds.has(reply.id)}
{@const replyIsOwner = isLoggedIn && currentUserId === reply.user_id}
<div class="rounded-md bg-zinc-800/30 px-3 py-2.5 flex flex-col gap-1.5 {replyDeleting ? 'opacity-50' : ''}">
<!-- Reply header -->
<div class="flex items-center gap-2 flex-wrap">
{#if avatarUrls[reply.user_id]}
<img src={avatarUrls[reply.user_id]} alt={reply.username} class="w-5 h-5 rounded-full object-cover flex-shrink-0" />
{:else}
<div class="w-5 h-5 rounded-full bg-zinc-700 flex items-center justify-center flex-shrink-0">
<span class="text-[8px] font-semibold text-zinc-300 leading-none">{initials(reply.username)}</span>
</div>
{/if}
{#if reply.username}
<a href="/users/{reply.username}" class="text-xs font-medium text-zinc-300 hover:text-amber-400 transition-colors">{reply.username}</a>
{:else}
<span class="text-xs font-medium text-zinc-400">Anonymous</span>
{/if}
<span class="text-zinc-600 text-xs">&middot;</span>
<span class="text-xs text-zinc-500">{formatDate(reply.created)}</span>
</div>
<!-- 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'}"
>
<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
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>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>

1
ui-v2/src/lib/index.ts Normal file
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,37 @@
/**
* Structured server-side logger.
*
* Emits JSON lines to stderr so they appear in container/process logs without
* polluting stdout (which Node's HTTP layer uses for responses).
*
* Format mirrors Go's log/slog default JSON output:
* {"time":"…","level":"ERROR","msg":"…","context":"pocketbase",...extra}
*
* Usage:
* import { log } from '$lib/server/logger';
* log.error('pocketbase', 'auth failed', { status: 401, url });
* log.warn('minio', 'presign slow', { slug, n, ms: elapsed });
* log.info('auth', 'user registered', { username });
*/
type Level = 'DEBUG' | 'INFO' | 'WARN' | 'ERROR';
type Extra = Record<string, unknown>;
function emit(level: Level, context: string, msg: string, extra?: Extra): void {
const entry: Record<string, unknown> = {
time: new Date().toISOString(),
level,
context,
msg,
...extra
};
// Write to stderr — never stdout
process.stderr.write(JSON.stringify(entry) + '\n');
}
export const log = {
debug: (context: string, msg: string, extra?: Extra) => emit('DEBUG', context, msg, extra),
info: (context: string, msg: string, extra?: Extra) => emit('INFO', context, msg, extra),
warn: (context: string, msg: string, extra?: Extra) => emit('WARN', context, msg, extra),
error: (context: string, msg: string, extra?: Extra) => emit('ERROR', context, msg, extra),
};

View File

@@ -0,0 +1,185 @@
/**
* Server-side MinIO presign helper.
* Calls the scraper 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';
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';
// ─── Avatar helpers ───────────────────────────────────────────────────────────
function extFromMime(mime: string): string {
if (mime.includes('png')) return 'png';
if (mime.includes('webp')) return 'webp';
if (mime.includes('gif')) return 'gif';
return 'jpg';
}
/**
* 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.
*/
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}`);
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`presign avatar upload failed: ${res.status} ${body}`);
}
const data = (await res.json()) as { upload_url: string; key: string };
return { uploadUrl: data.upload_url, key: data.key };
}
/**
* Returns a presigned GET URL for a user's avatar, rewritten to the public URL.
* 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)}`);
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;
}
/**
* Rewrites the MinIO host in a presigned URL to the public-facing URL.
*
* 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;
return u.toString();
} catch {
return presignedUrl;
}
}
/**
* Returns a presigned URL for a chapter markdown file.
* URL is valid for ~15 minutes (set by the scraper).
*
* @param rewrite - if true, rewrites the MinIO host to PUBLIC_MINIO_PUBLIC_URL
* (for browser use). Defaults to false — the server-side load function fetches
* the URL directly from the internal MinIO endpoint.
*/
export async function presignChapter(slug: string, n: number, rewrite = false): Promise<string> {
log.debug('minio', 'presigning chapter', { slug, n });
let res: Response;
try {
res = await fetch(`${SCRAPER_URL}/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`);
}
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('minio', 'presign chapter failed', { slug, n, status: res.status, body });
throw new Error(`presign chapter ${slug}/${n}: ${res.status}`);
}
const data = (await res.json()) as { url: string };
log.debug('minio', 'presign chapter ok', { slug, n });
return rewrite ? rewriteHost(data.url) : data.url;
}
/**
* Returns a presigned URL for a voice sample audio file.
* URL is valid for ~1 hour. The URL is returned to the browser for direct streaming.
* Throws with { status: 404 } when the sample has not been generated yet.
*/
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)}`);
} catch (e) {
log.error('minio', 'presign voice sample network error', { voice, err: String(e) });
throw new Error(`presign voice sample ${voice}: network error`);
}
if (res.status === 404) {
const err = new Error(`presign voice sample ${voice}: not found`) as Error & { status: number };
err.status = 404;
throw err;
}
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('minio', 'presign voice sample failed', { voice, status: res.status, body });
throw new Error(`presign voice sample ${voice}: ${res.status}`);
}
const data = (await res.json()) as { url: string };
log.debug('minio', 'presign voice sample ok', { voice });
return rewriteHost(data.url);
}
/**
* Returns a presigned URL for an audio file.
* URL is valid for ~1 hour. The URL is returned to the browser for direct streaming.
* Throws with { status: 404 } when the audio object has not been generated yet.
*/
export async function presignAudio(
slug: string,
n: number,
voice?: string
): Promise<string> {
const params = new URLSearchParams();
if (voice) params.set('voice', voice);
const qs = params.toString() ? `?${params.toString()}` : '';
log.debug('minio', 'presigning audio', { slug, n, voice });
let res: Response;
try {
res = await fetch(`${SCRAPER_URL}/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`);
}
if (res.status === 404) {
// Audio hasn't been generated / uploaded yet — caller should surface this as 404.
const err = new Error(`presign audio ${slug}/${n}: not found`) as Error & { status: number };
err.status = 404;
throw err;
}
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('minio', 'presign audio failed', { slug, n, status: res.status, body });
throw new Error(`presign audio ${slug}/${n}: ${res.status}`);
}
const data = (await res.json()) as { url: string };
log.debug('minio', 'presign audio ok', { slug, n });
return rewriteHost(data.url);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
/**
* In-process presign URL cache.
*
* 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, while avoiding a round-trip to
* the backend + MinIO presign API on every "Play" click.
*
* The cache is a plain Map in the Node.js module scope — it lives for the
* lifetime of the SvelteKit server process and is shared across all requests.
* No persistence, no distributed cache needed: each SvelteKit instance
* maintains its own cache and entries expire naturally.
*
* Voice-sample URLs use the same cache with key "sample:<voice>".
*/
const AUDIO_TTL_MS = 50 * 60 * 1000; // 50 minutes
interface CacheEntry {
url: string;
expiresAt: number; // Date.now() ms
}
const cache = new Map<string, CacheEntry>();
// ── Periodic sweep ────────────────────────────────────────────────────────────
// Remove stale entries every 10 minutes so the Map doesn't grow unboundedly
// in long-running processes. Uses unref() so it never prevents Node from
// exiting cleanly.
let sweepTimer: ReturnType<typeof setInterval> | null = null;
function startSweep() {
if (sweepTimer) return;
sweepTimer = setInterval(() => {
const now = Date.now();
for (const [key, entry] of cache) {
if (entry.expiresAt <= now) cache.delete(key);
}
}, 10 * 60 * 1000);
// Don't block Node.js exit
sweepTimer.unref?.();
}
startSweep();
// ── Public API ────────────────────────────────────────────────────────────────
/** 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}`;
}
/** Return the cached URL for key, or null if absent / expired. */
export function get(key: string): string | null {
const entry = cache.get(key);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
cache.delete(key);
return null;
}
return entry.url;
}
/** Store a presigned URL under key for TTL_MS milliseconds. */
export function set(key: string, url: string, ttlMs = AUDIO_TTL_MS): void {
cache.set(key, { url, expiresAt: Date.now() + ttlMs });
}
/** Invalidate a specific key (e.g. after audio generation to force refresh). */
export function invalidate(key: string): void {
cache.delete(key);
}
/** Drain all entries — called on graceful shutdown to release memory. */
export function drain(): void {
cache.clear();
if (sweepTimer) {
clearInterval(sweepTimer);
sweepTimer = null;
}
}
/** Current number of live (non-expired) cached entries. For health/debug. */
export function size(): number {
const now = Date.now();
let n = 0;
for (const entry of cache.values()) {
if (entry.expiresAt > now) n++;
}
return n;
}

View File

@@ -0,0 +1,32 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { getSettings } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
// Routes that are accessible without being logged in
const PUBLIC_ROUTES = new Set(['/login']);
export const load: LayoutServerLoad = async ({ locals, url }) => {
if (!PUBLIC_ROUTES.has(url.pathname) && !locals.user) {
redirect(302, `/login`);
}
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0 };
try {
const row = await getSettings(locals.sessionId, locals.user?.id);
if (row) {
settings = {
autoNext: row.auto_next ?? false,
voice: row.voice ?? 'af_bella',
speed: row.speed ?? 1.0
};
}
} catch (e) {
log.warn('layout', 'failed to load settings', { err: String(e) });
}
return {
user: locals.user,
settings
};
};

View File

@@ -0,0 +1,628 @@
<script lang="ts">
import '../app.css';
import { page, navigating } from '$app/state';
import { goto } from '$app/navigation';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types';
import { audioStore } from '$lib/audio.svelte';
import { env } from '$env/dynamic/public';
let { children, data }: { children: Snippet; data: LayoutData } = $props();
// Mobile nav drawer state
let menuOpen = $state(false);
// Chapter list drawer state for the mini-player
let chapterDrawerOpen = $state(false);
// The single <audio> element that persists across navigations.
// AudioPlayer components in chapter pages control it via audioStore.
let audioEl = $state<HTMLAudioElement | null>(null);
// Apply persisted settings once on mount (server-loaded data).
let settingsApplied = false;
$effect(() => {
if (!settingsApplied && data.settings) {
settingsApplied = true;
audioStore.autoNext = data.settings.autoNext;
audioStore.voice = data.settings.voice;
audioStore.speed = data.settings.speed;
}
});
// ── Persist settings changes (debounced 800ms) ──────────────────────────
let settingsSaveTimer = 0;
$effect(() => {
// Subscribe to the three settings fields
const autoNext = audioStore.autoNext;
const voice = audioStore.voice;
const speed = audioStore.speed;
// Skip saving until settings have been applied from the server
if (!settingsApplied) return;
clearTimeout(settingsSaveTimer);
settingsSaveTimer = setTimeout(() => {
fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed })
}).catch(() => {});
}, 800) as unknown as number;
});
// Keep the audio element's playback rate in sync with the store speed.
$effect(() => {
if (audioEl) audioEl.playbackRate = audioStore.speed;
});
// When audioUrl changes, load the new source.
// Use a local variable to track which URL is currently loaded so we never
// compare against audioEl.src (browsers normalise it, causing false mismatches).
let loadedUrl = '';
$effect(() => {
if (!audioEl) return;
const url = audioStore.audioUrl;
if (url && url !== loadedUrl) {
loadedUrl = url;
audioEl.src = url;
audioEl.load();
audioEl.playbackRate = audioStore.speed;
audioEl.play().catch(() => {});
} else if (!url) {
loadedUrl = '';
}
});
// Handle toggle requests from AudioPlayer controller.
$effect(() => {
// Read toggleRequest to subscribe; ignore value 0 (initial).
const _req = audioStore.toggleRequest;
if (!audioEl || _req === 0) return;
if (audioStore.isPlaying) {
audioEl.pause();
} else {
audioEl.play().catch(() => {});
}
});
// Handle seek requests from AudioPlayer controller.
$effect(() => {
const t = audioStore.seekRequest;
if (!audioEl || t === null) return;
audioEl.currentTime = t;
audioStore.seekRequest = null;
});
// ── Save audio time on pause/end (debounced 2s) ─────────────────────────
let audioTimeSaveTimer = 0;
function saveAudioTime() {
if (!audioStore.slug || !audioStore.chapter) return;
const slug = audioStore.slug;
const chapter = audioStore.chapter;
const currentTime = audioStore.currentTime;
clearTimeout(audioTimeSaveTimer);
audioTimeSaveTimer = setTimeout(() => {
fetch('/api/progress/audio-time', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, chapter, audioTime: currentTime })
}).catch(() => {});
}, 2000) as unknown as number;
}
function formatTime(s: number): string {
if (!isFinite(s) || s < 0) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
function togglePlay() {
if (!audioEl) return;
if (audioStore.isPlaying) {
audioEl.pause();
} else {
audioEl.play().catch(() => {});
}
}
function seek(e: Event) {
if (!audioEl) return;
audioEl.currentTime = parseFloat((e.target as HTMLInputElement).value);
}
function skipBack() {
if (!audioEl) return;
audioEl.currentTime = Math.max(0, audioEl.currentTime - 15);
}
function skipForward() {
if (!audioEl) return;
audioEl.currentTime = Math.min(audioEl.duration || 0, audioEl.currentTime + 30);
}
const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
function cycleSpeed() {
const idx = speedSteps.indexOf(audioStore.speed);
audioStore.speed = speedSteps[(idx + 1) % speedSteps.length];
}
function dismiss() {
if (audioEl) {
audioEl.pause();
audioEl.src = '';
}
audioStore.status = 'idle';
audioStore.audioUrl = '';
audioStore.slug = '';
audioStore.chapter = 0;
audioStore.isPlaying = false;
audioStore.currentTime = 0;
audioStore.duration = 0;
}
</script>
<svelte:head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>libnovel</title>
</svelte:head>
<!-- Persistent audio element — always in the DOM, never conditionally unmounted.
Conditional rendering ({#if}) would destroy/recreate it when reactive state
changes (e.g. currentTime ticking), triggering onpause and restarting audio. -->
<audio
bind:this={audioEl}
bind:currentTime={audioStore.currentTime}
bind:duration={audioStore.duration}
onplay={() => (audioStore.isPlaying = true)}
onpause={() => {
audioStore.isPlaying = false;
saveAudioTime();
}}
onended={() => {
audioStore.isPlaying = false;
saveAudioTime();
if (audioStore.autoNext && audioStore.nextChapter !== null && audioStore.slug) {
// Capture values synchronously before any async work — the AudioPlayer
// component will unmount during navigation, but we've already read what
// we need.
const targetSlug = audioStore.slug;
const targetChapter = audioStore.nextChapter;
// Store the target chapter number so only the newly-mounted AudioPlayer
// for that chapter reacts — not the outgoing chapter's component.
audioStore.autoStartChapter = targetChapter;
goto(`/books/${targetSlug}/chapters/${targetChapter}`).catch(() => {
audioStore.autoStartChapter = null;
});
}
}}
preload="metadata"
style="display:none"
></audio>
<div class="min-h-screen flex flex-col" class:pb-24={audioStore.active}>
<!-- Navigation progress bar — shown while SSR is running for any page transition -->
{#if navigating}
<div class="fixed top-0 left-0 right-0 z-[100] h-1 bg-zinc-800">
<div class="h-full bg-amber-400 animate-progress-bar"></div>
</div>
{/if}
<header class="border-b border-zinc-700 bg-zinc-900 sticky top-0 z-50">
<nav class="max-w-6xl mx-auto px-4 h-14 flex items-center gap-6">
<a href="/" class="text-amber-400 font-bold text-lg tracking-tight hover:text-amber-300 shrink-0">
libnovel
</a>
{#if page.data.book?.title && /\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
<span class="text-zinc-400 text-sm truncate min-w-0 flex-1 sm:flex-none sm:max-w-xs">
{page.data.book.title}
</span>
{/if}
{#if data.user}
<!-- Desktop nav links (hidden on mobile) -->
<a
href="/books"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/books') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
>
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>
<div class="ml-auto flex items-center gap-4">
<!-- Desktop: admin + profile + sign out (hidden on mobile) -->
{#if data.user?.role === 'admin'}
<a
href="/admin/scrape"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin/scrape') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
>
Scrape
</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'}"
>
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
</a>
{/if}
<a
href="/profile"
class="hidden sm:block text-sm transition-colors {page.url.pathname === '/profile' ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
>
{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">
Sign out
</button>
</form>
<!-- Mobile: hamburger button -->
<button
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"
>
{#if menuOpen}
<!-- X icon -->
<svg class="w-5 h-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>
{:else}
<!-- Hamburger icon -->
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
{/if}
</button>
</div>
{:else}
<div class="ml-auto">
<a
href="/login"
class="text-sm px-3 py-1.5 rounded bg-amber-400 text-zinc-900 font-semibold hover:bg-amber-300 transition-colors"
>
Sign in
</a>
</div>
{/if}
</nav>
<!-- Mobile drawer (full-width, below the bar) -->
{#if data.user && menuOpen}
<div class="sm:hidden border-t border-zinc-700 bg-zinc-900 px-4 py-3 flex flex-col gap-1">
<a
href="/books"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/books') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
>
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="/profile"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/profile' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
>
Profile <span class="text-zinc-500 font-normal">({data.user.username})</span>
</a>
{#if data.user?.role === 'admin'}
<div class="my-1 border-t border-zinc-700/60"></div>
<p class="px-3 pt-1 pb-0.5 text-xs text-zinc-600 uppercase tracking-widest">Admin</p>
<a
href="/admin/scrape"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin/scrape') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
>
Scrape tasks
</a>
<a
href="/admin/audio"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname === '/admin/audio' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
>
Audio cache
</a>
<a
href="/admin/audio-jobs"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/admin/audio-jobs') ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100'}"
>
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>
{/if}
</header>
<main class="flex-1 max-w-6xl mx-auto w-full px-4 py-8">
{#key page.url.pathname + page.url.search}
{@render children()}
{/key}
</main>
<footer class="border-t border-zinc-800 mt-auto">
<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"
target="_blank"
rel="noopener noreferrer"
class="hover:text-zinc-400 transition-colors flex items-center gap-1"
>
novelfire.net
<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>
</nav>
<!-- Bottom row: legal links + copyright -->
<div class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-zinc-700">
<a href="/disclaimer" class="hover:text-zinc-500 transition-colors">Disclaimer</a>
<a href="/privacy" class="hover:text-zinc-500 transition-colors">Privacy</a>
<a href="/dmca" class="hover:text-zinc-500 transition-colors">DMCA</a>
<span>&copy; {new Date().getFullYear()} libnovel</span>
{#if env.PUBLIC_BUILD_VERSION && env.PUBLIC_BUILD_VERSION !== 'dev'}
<span class="text-zinc-800">{env.PUBLIC_BUILD_VERSION}+{env.PUBLIC_BUILD_COMMIT?.slice(0, 7)}</span>
{/if}
</div>
</div>
</footer>
</div>
<!-- ── Persistent mini-player bar ─────────────────────────────────────────── -->
{#if audioStore.active}
<div class="fixed bottom-0 left-0 right-0 z-50 bg-zinc-900 border-t border-zinc-700 shadow-2xl">
<!-- Chapter list drawer (slides up above the mini-bar) -->
{#if chapterDrawerOpen && audioStore.chapters.length > 0}
<div class="border-b border-zinc-700 bg-zinc-900 max-h-[32rem] overflow-y-auto">
<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
onclick={() => (chapterDrawerOpen = false)}
class="text-zinc-600 hover:text-zinc-300 transition-colors p-1"
aria-label="Close chapter list"
>
<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>
</div>
{#each audioStore.chapters as ch (ch.number)}
<a
href="/books/{audioStore.slug}/chapters/{ch.number}"
onclick={() => (chapterDrawerOpen = false)}
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-zinc-100 {ch.number === audioStore.chapter
? 'text-amber-400 font-semibold'
: 'text-zinc-400'}"
>
<span class="tabular-nums text-zinc-600 w-8 shrink-0 text-right">
{ch.number}
</span>
<span class="truncate">{ch.title || `Chapter ${ch.number}`}</span>
{#if ch.number === audioStore.chapter}
<svg class="w-3 h-3 shrink-0 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</a>
{/each}
</div>
</div>
{/if}
<!-- Generation progress bar (sits at very top of the bar) -->
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
<div class="h-0.5 bg-zinc-800">
<div
class="h-full bg-amber-400 transition-none"
style="width: {audioStore.progress}%"
></div>
</div>
{:else if audioStore.status === 'ready'}
<!-- Seek bar flush at top — tappable on mobile -->
<div class="px-0">
<input
type="range"
min="0"
max={audioStore.duration || 0}
value={audioStore.currentTime}
oninput={seek}
class="w-full h-1 accent-amber-400 cursor-pointer block"
style="margin: 0; border-radius: 0;"
/>
</div>
{/if}
<div class="max-w-6xl mx-auto px-4 py-2 flex items-center gap-3">
<!-- Track info (click to open chapter list drawer) -->
<button
class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-zinc-800 transition-colors"
onclick={() => { if (audioStore.chapters.length > 0) chapterDrawerOpen = !chapterDrawerOpen; }}
aria-label={audioStore.chapters.length > 0 ? 'Toggle chapter list' : undefined}
title={audioStore.chapters.length > 0 ? 'Chapter list' : undefined}
>
{#if audioStore.chapterTitle}
<p class="text-xs text-zinc-100 truncate leading-tight">{audioStore.chapterTitle}</p>
{/if}
{#if audioStore.bookTitle}
<p class="text-xs text-zinc-500 truncate leading-tight">{audioStore.bookTitle}</p>
{/if}
{#if audioStore.status === 'generating'}
<p class="text-xs text-amber-400 leading-tight">
Generating… {Math.round(audioStore.progress)}%
</p>
{:else if audioStore.status === 'ready'}
<p class="text-xs text-zinc-500 tabular-nums leading-tight">
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
</p>
{:else if audioStore.status === 'loading'}
<p class="text-xs text-zinc-500 leading-tight">Loading…</p>
{/if}
</button>
{#if audioStore.status === 'ready'}
<!-- Skip back 15s -->
<button
onclick={skipBack}
class="text-zinc-400 hover:text-zinc-100 transition-colors p-1.5 rounded"
title="Back 15s"
aria-label="Rewind 15 seconds"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<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>
<!-- Play / Pause -->
<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"
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
>
{#if audioStore.isPlaying}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
{:else}
<svg class="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
<!-- Skip forward 30s -->
<button
onclick={skipForward}
class="text-zinc-400 hover:text-zinc-100 transition-colors p-1.5 rounded"
title="Forward 30s"
aria-label="Skip 30 seconds"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<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>
<!-- Speed control -->
<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"
title="Change playback speed"
aria-label="Playback speed {audioStore.speed}x"
>
{audioStore.speed}×
</button>
<!-- Auto-next toggle (with prefetch indicator) -->
<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'}"
title={audioStore.autoNext
? audioStore.nextStatus === 'prefetched'
? `Auto-next on Ch.${audioStore.nextChapter} ready`
: audioStore.nextStatus === 'prefetching'
? `Auto-next on preparing Ch.${audioStore.nextChapter}`
: 'Auto-next on'
: 'Auto-next off'}
aria-label="Auto-next {audioStore.autoNext ? 'on' : 'off'}"
aria-pressed={audioStore.autoNext}
>
<!-- "skip to end" / auto-advance icon -->
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
</svg>
<!-- Prefetch status dot -->
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></span>
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-green-400"></span>
{/if}
</button>
{:else if audioStore.status === 'generating'}
<!-- Spinner during generation -->
<svg class="w-6 h-6 text-amber-400 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>
{/if}
<!-- Cover thumbnail / go-to-chapter link -->
{#if audioStore.slug && audioStore.chapter > 0}
<a
href="/books/{audioStore.slug}/chapters/{audioStore.chapter}"
class="shrink-0 rounded overflow-hidden hover:opacity-80 transition-opacity"
title="Go to chapter"
aria-label="Go to chapter"
>
{#if audioStore.cover}
<img
src={audioStore.cover}
alt=""
class="w-8 h-11 object-cover rounded"
/>
{:else}
<!-- Fallback book icon -->
<div class="w-8 h-11 flex items-center justify-center bg-zinc-800 rounded border border-zinc-700">
<svg class="w-4 h-4 text-zinc-500" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
</svg>
</div>
{/if}
</a>
{/if}
<!-- Dismiss -->
<button
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"
>
<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>
</div>
</div>
{/if}

View File

@@ -0,0 +1,59 @@
import type { PageServerLoad } from './$types';
import {
listBooks,
recentlyAddedBooks,
allProgress,
getHomeStats,
getSubscriptionFeed
} from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import type { Book, Progress } from '$lib/server/pocketbase';
export const load: PageServerLoad = async ({ locals }) => {
let allBooks: Book[] = [];
let recentBooks: Book[] = [];
let progressList: Progress[] = [];
let stats = { totalBooks: 0, totalChapters: 0 };
try {
[allBooks, recentBooks, progressList, stats] = await Promise.all([
listBooks(),
recentlyAddedBooks(8),
allProgress(locals.sessionId, locals.user?.id),
getHomeStats()
]);
} catch (e) {
log.error('home', 'failed to load home data', { err: String(e) });
}
// Build slug → book lookup
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
// Continue reading: progress entries joined with book data, most recent first
const continueReading = progressList
.filter((p) => bookMap.has(p.slug))
.slice(0, 6)
.map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter }));
// Recently updated: deduplicate against continueReading slugs
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
// Subscription feed — only when logged in
const subscriptionFeed = locals.user
? await getSubscriptionFeed(locals.user.id, 12).catch((e) => {
log.error('home', 'failed to load subscription feed', { err: String(e) });
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
})
: [];
return {
continueReading,
recentlyUpdated,
subscriptionFeed,
stats: {
...stats,
booksInProgress: continueReading.length
}
};
};

View File

@@ -0,0 +1,202 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
function parseGenres(genres: string[] | string | null | undefined): string[] {
if (!genres) return [];
if (Array.isArray(genres)) return genres;
try {
const parsed = JSON.parse(genres);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
</script>
<svelte:head>
<title>libnovel</title>
</svelte:head>
<!-- Stats bar -->
<div class="flex gap-6 mb-8 text-center">
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6">
<p class="text-2xl font-bold text-amber-400">{data.stats.totalBooks}</p>
<p class="text-xs text-zinc-400 mt-0.5">Books</p>
</div>
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6">
<p class="text-2xl font-bold text-amber-400">{data.stats.totalChapters.toLocaleString()}</p>
<p class="text-xs text-zinc-400 mt-0.5">Chapters</p>
</div>
<div class="flex-1 rounded-lg bg-zinc-800 border border-zinc-700 py-4 px-6">
<p class="text-2xl font-bold text-amber-400">{data.stats.booksInProgress}</p>
<p class="text-xs text-zinc-400 mt-0.5">In progress</p>
</div>
</div>
<!-- Continue Reading -->
{#if data.continueReading.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-lg font-bold text-zinc-100">Continue Reading</h2>
<a href="/books" class="text-xs text-amber-400 hover:text-amber-300">View all</a>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.continueReading as { book, chapter }}
<a
href="/books/{book.slug}/chapters/{chapter}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
<!-- Chapter badge overlay -->
<span class="absolute bottom-1.5 right-1.5 text-xs bg-amber-400 text-zinc-900 font-bold px-1.5 py-0.5 rounded">
ch.{chapter}
</span>
</div>
<div class="p-2">
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author}
<p class="text-xs text-zinc-500 truncate mt-0.5">{book.author}</p>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- Recently Updated -->
{#if data.recentlyUpdated.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-lg font-bold text-zinc-100">Recently Updated</h2>
<a href="/books" class="text-xs text-amber-400 hover:text-amber-300">View all</a>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.recentlyUpdated as book}
{@const genres = parseGenres(book.genres)}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
</div>
<div class="p-2 flex flex-col gap-1">
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author}
<p class="text-xs text-zinc-400 truncate">{book.author}</p>
{/if}
{#if book.status}
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300 self-start">{book.status}</span>
{/if}
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-1">
{#each genres.slice(0, 2) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- Empty state -->
{#if data.continueReading.length === 0 && data.recentlyUpdated.length === 0}
<div class="text-center py-20 text-zinc-500">
<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"
class="inline-block px-6 py-3 bg-amber-400 text-zinc-900 font-semibold rounded hover:bg-amber-300 transition-colors"
>
Discover Novels
</a>
</div>
{/if}
<!-- From Subscriptions -->
{#if data.subscriptionFeed.length > 0}
<section class="mb-10">
<div class="flex items-baseline justify-between mb-3">
<h2 class="text-lg font-bold text-zinc-100">From People You Follow</h2>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.subscriptionFeed as { book, readerUsername }}
{@const genres = parseGenres(book.genres)}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
</div>
<div class="p-2 flex flex-col gap-1">
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title ?? ''}</h3>
{#if book.author}
<p class="text-xs text-zinc-400 truncate">{book.author}</p>
{/if}
<!-- Reader attribution -->
<p class="text-xs text-zinc-600 truncate mt-0.5">
via <span class="text-amber-500/70">{readerUsername}</span>
</p>
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-auto pt-1">
{#each genres.slice(0, 1) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let entries = $state(untrack(() => data.entries));
// ── 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: '—' };
}
function fmtDate(s: string) {
if (!s) return '—';
return new Date(s).toLocaleString(undefined, {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
}
// ── Search ──────────────────────────────────────────────────────────────────
let q = $state('');
let filtered = $derived(
q.trim()
? entries.filter((e) => e.cache_key.toLowerCase().includes(q.toLowerCase().trim()))
: entries
);
</script>
<svelte:head>
<title>Audio cache — libnovel admin</title>
</svelte:head>
<div class="space-y-6">
<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>
</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"
/>
{#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>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>

View File

@@ -0,0 +1,29 @@
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';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user?.role !== 'admin') {
redirect(302, '/');
}
const [tasks, statusRes] = await Promise.all([
listScrapingTasks().catch((e) => {
log.warn('admin/scrape', 'failed to load tasks', { err: String(e) });
return [];
}),
fetch(`${SCRAPER_URL}/api/scrape/status`).catch(() => null)
]);
let running = false;
if (statusRes?.ok) {
const body = await statusRes.json().catch(() => null);
running = body?.running ?? false;
}
return { tasks, running };
};

View File

@@ -0,0 +1,238 @@
<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(untrack(() => data.running));
let tasks = $state(untrack(() => data.tasks));
let polling = $state(false);
// Poll every 5 s while a job is running
$effect(() => {
if (!running) return;
const id = setInterval(async () => {
const res = await fetch('/api/admin/scrape').catch(() => null);
if (res?.ok) {
const body = await res.json().catch(() => null);
running = body?.running ?? false;
if (!running) {
// Refresh tasks list once job finishes
await invalidateAll();
}
}
}, 5000);
return () => clearInterval(id);
});
// Keep local state in sync when server re-loads
$effect(() => {
running = data.running;
tasks = data.tasks;
});
// ── Trigger scrape ──────────────────────────────────────────────────────────
let scrapeUrl = $state('');
let scrapeError = $state('');
let scraping = $state(false);
async function triggerScrape(url?: string) {
if (running || scraping) 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)
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
scrapeError = data.error ?? data.message ?? `Error ${res.status}`;
} else {
running = true;
if (url) scrapeUrl = '';
}
} catch {
scrapeError = 'Network error.';
} finally {
scraping = false;
}
}
// ── Cancel task ─────────────────────────────────────────────────────────────
// Tracks which task IDs are currently being cancelled (to disable the button).
let cancellingIds = $state(new Set<string>());
let cancelErrors: Record<string, string> = $state({});
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 {
// Optimistically flip status in the local list so the button disappears immediately.
tasks = tasks.map((t: ScrapingTask) => (t.id === id ? { ...t, status: 'cancelled' } : t));
}
} catch {
cancelErrors = { ...cancelErrors, [id]: 'Network error.' };
} finally {
cancellingIds = new Set([...cancellingIds].filter((x) => x !== id));
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function statusColor(status: string) {
if (status === 'done') return 'text-green-400';
if (status === 'running') return 'text-amber-400 animate-pulse';
if (status === 'failed') return 'text-red-400';
if (status === 'cancelled') return 'text-zinc-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`;
}
</script>
<svelte:head>
<title>Scrape tasks — libnovel admin</title>
</svelte:head>
<div class="space-y-8">
<div class="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 class="text-2xl font-bold text-zinc-100">Scrape tasks</h1>
<p class="text-zinc-400 text-sm mt-1">
Job status:
{#if running}
<span class="text-amber-400 font-medium animate-pulse">Running</span>
{:else}
<span class="text-green-400 font-medium">Idle</span>
{/if}
</p>
</div>
<!-- Trigger controls -->
<div class="flex flex-wrap gap-3 items-start">
<button
onclick={() => triggerScrape()}
disabled={running || scraping}
class="px-4 py-2 rounded-lg bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors disabled:opacity-50"
>
Full catalogue scrape
</button>
</div>
</div>
<!-- Single book scrape -->
<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"
>
Scrape
</button>
</div>
{#if scrapeError}
<p class="text-sm text-red-400">{scrapeError}</p>
{/if}
</div>
<!-- Tasks table -->
{#if tasks.length === 0}
<p class="text-zinc-500 text-sm py-8 text-center">No scrape tasks yet.</p>
{:else}
<div class="overflow-x-auto rounded-xl border border-zinc-700">
<table class="w-full text-sm">
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
<tr>
<th class="px-4 py-3 text-left">Kind</th>
<th class="px-4 py-3 text-left">Status</th>
<th class="px-4 py-3 text-right">Books</th>
<th class="px-4 py-3 text-right">Chapters</th>
<th class="px-4 py-3 text-right">Skipped</th>
<th class="px-4 py-3 text-right">Errors</th>
<th class="px-4 py-3 text-left">Started</th>
<th class="px-4 py-3 text-left">Duration</th>
<th class="px-4 py-3 text-left">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-700/50">
{#each tasks as task}
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
<td class="px-4 py-3 font-mono text-xs text-zinc-300">
{task.kind}
{#if task.target_url}
<br />
<span class="text-zinc-500 truncate max-w-[16rem] block" title={task.target_url}>
{task.target_url.replace('https://novelfire.net/book/', '')}
</span>
{/if}
</td>
<td class="px-4 py-3">
<span class="font-medium {statusColor(task.status)}">{task.status}</span>
</td>
<td class="px-4 py-3 text-right text-zinc-300">{task.books_found ?? 0}</td>
<td class="px-4 py-3 text-right text-zinc-300">{task.chapters_scraped ?? 0}</td>
<td class="px-4 py-3 text-right text-zinc-400">{task.chapters_skipped ?? 0}</td>
<td class="px-4 py-3 text-right {task.errors > 0 ? 'text-red-400' : 'text-zinc-400'}">{task.errors ?? 0}</td>
<td class="px-4 py-3 text-zinc-400">{fmtDate(task.started)}</td>
<td class="px-4 py-3 text-zinc-400">{duration(task.started, task.finished)}</td>
<td class="px-4 py-3">
{#if task.status === 'pending'}
<button
onclick={() => cancelTask(task.id)}
disabled={cancellingIds.has(task.id)}
class="px-2 py-1 rounded text-xs font-medium bg-zinc-700 text-zinc-300 hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
title="Cancel this task"
>
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel'}
</button>
{#if cancelErrors[task.id]}
<p class="text-xs text-red-400 mt-1">{cancelErrors[task.id]}</p>
{/if}
{/if}
</td>
</tr>
{#if task.error_message}
<tr class="bg-red-950/20">
<td colspan="9" class="px-4 py-2 text-xs text-red-400 font-mono">{task.error_message}</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
</div>

View File

@@ -0,0 +1,23 @@
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';
/**
* GET /api/admin/scrape/status
* Admin-only proxy to the Go scraper'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`);
if (!res.ok) return json({ running: false });
const data = await res.json();
return json({ running: data.running ?? false });
} catch {
return json({ running: false });
}
};

View File

@@ -0,0 +1,100 @@
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';
/**
* 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.
*
* Body: { voice?: string }
*
* Responses:
* 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 }) => {
const { slug, n } = params;
const chapter = parseInt(n, 10);
if (!slug || !chapter || chapter < 1) {
error(400, 'Invalid slug or chapter number');
}
let body: { voice?: string } = {};
try {
body = await request.json();
} catch {
// empty body is fine — scraper will use defaults
}
const scraperRes = await fetch(`${SCRAPER_URL}/api/audio/${slug}/${chapter}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!scraperRes.ok) {
const text = await scraperRes.text().catch(() => '');
log.error('audio', 'scraper 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; status: 'done' }
| { task_id: string; status: string };
// 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.
// 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({ 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.
* Kept as a fallback but no longer used as the primary playback path —
* AudioPlayer fetches a presigned MinIO URL directly via /api/presign/audio.
*/
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

@@ -0,0 +1,66 @@
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';
/**
* GET /api/audio/status/[slug]/[n]?voice=...
* Proxies the audio generation status check to the scraper's
* GET /api/audio/status/{slug}/{n} endpoint.
*
* 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'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;
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/status/${slug}/${chapter}?${qs.toString()}`
);
if (!scraperRes.ok) {
const text = await scraperRes.text().catch(() => '');
log.error('audio', 'scraper audio status check failed', {
slug,
chapter,
status: scraperRes.status,
body: text
});
error(scraperRes.status as Parameters<typeof error>[0], text || 'Status check failed');
}
const data = (await scraperRes.json()) as {
status: string;
task_id?: string;
url?: string;
error?: string;
};
// Strip the scraper'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), {
headers: { 'Content-Type': 'application/json' }
});
};

View File

@@ -0,0 +1,11 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
/**
* POST /api/audio/voice-samples
* The new backend does not expose a voice-samples generation endpoint.
* Return 501 so callers get a clear signal rather than a 502 proxy error.
*/
export const POST: RequestHandler = async () => {
return json({ error: 'Voice sample pre-generation is not supported by this backend.' }, { status: 501 });
};

View File

@@ -0,0 +1,47 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { changePassword } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* POST /api/auth/change-password
* Body: { currentPassword: string, newPassword: string }
* Requires authentication.
*/
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
error(401, 'Not authenticated');
}
let body: { currentPassword?: string; newPassword?: string };
try {
body = await request.json();
} catch {
error(400, 'Invalid JSON body');
}
const currentPassword = body.currentPassword ?? '';
const newPassword = body.newPassword ?? '';
if (!currentPassword || !newPassword) {
error(400, 'currentPassword and newPassword are required');
}
if (newPassword.length < 4) {
error(400, 'New password must be at least 4 characters');
}
try {
const ok = await changePassword(locals.user.id, currentPassword, newPassword);
if (!ok) {
error(401, 'Current password is incorrect');
}
} catch (e: unknown) {
// Re-throw SvelteKit errors as-is
if (e && typeof e === 'object' && 'status' in e) throw e;
log.error('api/auth/change-password', 'unexpected error', { err: String(e) });
error(500, 'An error occurred. Please try again.');
}
return json({ ok: true });
};

View File

@@ -0,0 +1,75 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { loginUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase';
import { createAuthToken } from '../../../../hooks.server';
import { log } from '$lib/server/logger';
import { randomBytes } from 'node:crypto';
const AUTH_COOKIE = 'libnovel_auth';
const ONE_YEAR = 60 * 60 * 24 * 365;
/**
* POST /api/auth/login
* Body: { username: string, password: string }
* Returns: { token: string, user: { id, username, role } }
*
* Sets the libnovel_auth cookie and returns the raw token value so the
* iOS app can persist it for subsequent requests.
*/
export const POST: RequestHandler = async ({ request, cookies, locals }) => {
let body: { username?: string; password?: string };
try {
body = await request.json();
} catch {
error(400, 'Invalid JSON body');
}
const username = (body.username ?? '').trim();
const password = body.password ?? '';
if (!username || !password) {
error(400, 'Username and password are required');
}
let user;
try {
user = await loginUser(username, password);
} catch (e) {
log.error('api/auth/login', 'unexpected error', { username, err: String(e) });
error(500, 'An error occurred. Please try again.');
}
if (!user) {
error(401, 'Invalid username or password');
}
// Merge anonymous session progress (non-fatal)
mergeSessionProgress(locals.sessionId, user.id).catch((e) =>
log.warn('api/auth/login', 'mergeSessionProgress failed (non-fatal)', { err: String(e) })
);
const authSessionId = randomBytes(16).toString('hex');
const userAgent = request.headers.get('user-agent') ?? '';
const ip =
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
request.headers.get('x-real-ip') ??
'';
createUserSession(user.id, authSessionId, userAgent, ip).catch((e) =>
log.warn('api/auth/login', 'createUserSession failed (non-fatal)', { err: String(e) })
);
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
cookies.set(AUTH_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: ONE_YEAR
});
return json({
token,
user: { id: user.id, username: user.username, role: user.role ?? 'user' }
});
};

View File

@@ -0,0 +1,15 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const AUTH_COOKIE = 'libnovel_auth';
/**
* POST /api/auth/logout
* Clears the auth cookie and returns { ok: true }.
* Does not revoke the session record from PocketBase —
* for full revocation use DELETE /api/sessions/[id] first.
*/
export const POST: RequestHandler = async ({ cookies }) => {
cookies.delete(AUTH_COOKIE, { path: '/' });
return json({ ok: true });
};

View File

@@ -0,0 +1,22 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getUserByUsername } from '$lib/server/pocketbase';
/**
* GET /api/auth/me
* Returns the currently authenticated user from the request's auth cookie.
* Returns 401 if not authenticated.
*/
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) {
error(401, 'Not authenticated');
}
// Fetch full record from PocketBase to get avatar_url
const record = await getUserByUsername(locals.user.username).catch(() => null);
return json({
id: locals.user.id,
username: locals.user.username,
role: locals.user.role,
avatar_url: record?.avatar_url ?? null
});
};

View File

@@ -0,0 +1,84 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase';
import { createAuthToken } from '../../../../hooks.server';
import { log } from '$lib/server/logger';
import { randomBytes } from 'node:crypto';
const AUTH_COOKIE = 'libnovel_auth';
const ONE_YEAR = 60 * 60 * 24 * 365;
/**
* POST /api/auth/register
* Body: { username: string, password: string }
* Returns: { token: string, user: { id, username, role } }
*
* Sets the libnovel_auth cookie and returns the raw token value so the
* iOS app can persist it for subsequent requests.
*/
export const POST: RequestHandler = async ({ request, cookies, locals }) => {
let body: { username?: string; password?: string };
try {
body = await request.json();
} catch {
error(400, 'Invalid JSON body');
}
const username = (body.username ?? '').trim();
const password = body.password ?? '';
if (!username || !password) {
error(400, 'Username and password are required');
}
if (username.length < 3 || username.length > 32) {
error(400, 'Username must be between 3 and 32 characters');
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
error(400, 'Username may only contain letters, numbers, underscores and hyphens');
}
if (password.length < 8) {
error(400, 'Password must be at least 8 characters');
}
let user;
try {
user = await createUser(username, password);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Registration failed.';
if (msg.includes('Username already taken')) {
error(409, 'That username is already taken');
}
log.error('api/auth/register', 'unexpected error', { username, err: String(e) });
error(500, 'An error occurred. Please try again.');
}
// Merge anonymous session progress (non-fatal)
mergeSessionProgress(locals.sessionId, user.id).catch((e) =>
log.warn('api/auth/register', 'mergeSessionProgress failed (non-fatal)', { err: String(e) })
);
const authSessionId = randomBytes(16).toString('hex');
const userAgent = request.headers.get('user-agent') ?? '';
const ip =
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
request.headers.get('x-real-ip') ??
'';
createUserSession(user.id, authSessionId, userAgent, ip).catch((e) =>
log.warn('api/auth/register', 'createUserSession failed (non-fatal)', { err: String(e) })
);
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
cookies.set(AUTH_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: ONE_YEAR
});
return json({
token,
user: { id: user.id, username: user.username, role: user.role ?? 'user' }
});
};

View File

@@ -0,0 +1,111 @@
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';
/**
* GET /api/book/[slug]
* Returns book metadata, chapter list, progress, and library status.
*
* 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;
// Try PocketBase first
let book = await getBook(slug).catch((e) => {
log.error('api/book', 'getBook failed', { slug, err: String(e) });
return null;
});
if (book) {
let chapters, progress, saved;
try {
[chapters, progress, saved] = await Promise.all([
listChapterIdx(slug),
getProgress(locals.sessionId, slug, locals.user?.id),
isBookSaved(locals.sessionId, slug, locals.user?.id)
]);
} catch (e) {
log.error('api/book', 'failed to load book detail data', { slug, err: String(e) });
error(500, 'Failed to load book');
}
return json({
book,
chapters,
in_lib: true,
saved,
last_chapter: progress?.chapter ?? null,
scraping: false,
task_id: null
});
}
// Fall back to backend: enqueue scrape task if not in library.
try {
const res = await fetch(`${SCRAPER_URL}/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`);
}
// 200 — book was already in library
const preview: {
in_lib: boolean;
meta: {
slug: string;
title: string;
author: string;
cover: string;
status: string;
genres: string[];
summary: string;
total_chapters: number;
source_url: string;
};
chapters: { number: number; title: string; date?: string }[];
} = await res.json();
const previewBook = {
id: '',
slug: preview.meta.slug || slug,
title: preview.meta.title,
author: preview.meta.author,
cover: preview.meta.cover,
status: preview.meta.status,
genres: preview.meta.genres ?? [],
summary: preview.meta.summary,
total_chapters: preview.meta.total_chapters,
source_url: preview.meta.source_url,
ranking: 0,
meta_updated: ''
};
return json({
book: previewBook,
chapters: preview.chapters,
in_lib: true,
saved: false,
last_chapter: null,
scraping: false,
task_id: null
});
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
log.error('api/book', 'book-preview fetch failed', { slug, err: String(e) });
error(404, `Book "${slug}" not found`);
}
};

View File

@@ -0,0 +1,37 @@
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,46 @@
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';
/**
* GET /api/chapter-text-preview/[slug]/[n]
* Proxies to the scraper'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).
*/
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');
}
// Forward optional query params (chapter_url, title) if present
const qs = new URLSearchParams();
const chapterUrl = url.searchParams.get('chapter_url');
const title = url.searchParams.get('title');
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()}`
).catch((e) => {
log.error('chapter-preview', 'scraper fetch failed', { slug, chapter, err: String(e) });
return null;
});
if (!scraperRes || !scraperRes.ok) {
const status = scraperRes?.status ?? 502;
log.error('chapter-preview', 'scraper returned error', { slug, chapter, status });
error(status as Parameters<typeof error>[0], 'Chapter preview not available');
}
const data = await scraperRes.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
};

View File

@@ -0,0 +1,128 @@
import { json, error } from '@sveltejs/kit';
import { marked } from 'marked';
import type { RequestHandler } from './$types';
import { getBook, listChapterIdx } 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';
/**
* GET /api/chapter/[slug]/[n]
* Returns rendered chapter HTML, navigation info, and voice list.
* Supports ?preview=1&chapter_url=...&title=... for un-scraped books.
*
* Response shape mirrors ChapterResponse in the iOS APIClient.
*/
export const GET: RequestHandler = async ({ params, url, locals }) => {
const { slug } = params;
const n = parseInt(params.n, 10);
if (!n || n < 1) error(400, 'Invalid chapter number');
const isPreview = url.searchParams.get('preview') === '1';
const chapterUrl = url.searchParams.get('chapter_url') ?? '';
const chapterTitle = url.searchParams.get('title') ?? '';
if (isPreview) {
// Preview path: scrape live, nothing from PocketBase/MinIO
const previewParams = new URLSearchParams();
if (chapterUrl) previewParams.set('chapter_url', chapterUrl);
if (chapterTitle) previewParams.set('title', chapterTitle);
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()}`
);
if (!res.ok) {
log.error('api/chapter', 'chapter-text-preview returned error', { slug, n, status: res.status });
error(404, `Chapter ${n} not found`);
}
chapterData = await res.json();
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
log.error('api/chapter', 'chapter-text-preview fetch failed', { slug, n, err: String(e) });
error(502, 'Could not fetch chapter preview');
}
const html = chapterData.text
? '<p>' + chapterData.text.replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
: '';
let voices: string[] = [];
try {
const vRes = await fetch(`${SCRAPER_URL}/api/voices`);
if (vRes.ok) {
const d = (await vRes.json()) as { voices: string[] };
voices = d.voices ?? [];
}
} catch {
// Non-critical
}
const pb = await getBook(slug).catch(() => null);
return json({
book: { slug, title: pb?.title ?? slug, cover: pb?.cover ?? '' },
chapter: { id: '', slug, number: n, title: chapterData.title || `Chapter ${n}`, date_label: '' },
html,
voices,
prev: null,
next: null,
chapters: [],
is_preview: true
});
}
// Normal path: PocketBase + MinIO
const [book, chapters, voicesRes] = await Promise.all([
getBook(slug),
listChapterIdx(slug),
fetch(`${SCRAPER_URL}/api/voices`).catch(() => null)
]);
if (!book) error(404, `Book "${slug}" not found`);
const chapterIdx = chapters.find((c) => c.number === n);
if (!chapterIdx) error(404, `Chapter ${n} not found`);
let voices: string[] = [];
try {
if (voicesRes?.ok) {
const data = (await voicesRes.json()) as { voices: string[] };
voices = data.voices ?? [];
}
} catch {
// Non-critical
}
let html = '';
try {
const res = await fetch(`${SCRAPER_URL}/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;
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
return json({
book: { slug: book.slug, title: book.title, cover: book.cover ?? '' },
chapter: chapterIdx,
html,
voices,
prev: prevChapter ? prevChapter.number : null,
next: nextChapter ? nextChapter.number : null,
chapters: chapters.map((c) => ({ number: c.number, title: c.title })),
is_preview: false
});
};

View File

@@ -0,0 +1,26 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { deleteComment } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* DELETE /api/comment/[id]
* Deletes a comment and its replies. Only the comment owner may delete.
* Requires authentication.
*/
export const DELETE: RequestHandler = async ({ params, locals }) => {
if (!locals.user) error(401, 'Login required');
const { id } = params;
try {
await deleteComment(id, locals.user.id);
return new Response(null, { status: 204 });
} catch (e) {
const msg = String(e);
if (msg.includes('Not authorized')) error(403, 'Not authorized to delete this comment');
if (msg.includes('not found')) error(404, 'Comment not found');
log.error('api/comment/[id]', 'deleteComment failed', { id, err: msg });
error(500, 'Failed to delete comment');
}
};

View File

@@ -0,0 +1,33 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { voteComment } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* POST /api/comment/[id]/vote
* Body: { vote: 'up' | 'down' }
* Casts, changes, or toggles off a vote on a comment.
* Works for both authenticated and anonymous users (session-scoped).
* Returns the updated comment.
*/
export const POST: RequestHandler = async ({ params, request, locals }) => {
const { id } = params;
let body: { vote?: string };
try {
body = await request.json();
} catch {
error(400, 'Invalid JSON body');
}
if (body.vote !== 'up' && body.vote !== 'down') {
error(400, 'vote must be "up" or "down"');
}
try {
const updated = await voteComment(id, body.vote, locals.sessionId, locals.user?.id);
return json(updated);
} catch (e) {
log.error('api/comment/[id]/vote', 'voteComment failed', { id, err: String(e) });
error(500, 'Failed to record vote');
}
};

View File

@@ -0,0 +1,102 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
listComments,
listReplies,
createComment,
getMyVotes,
type CommentSort
} from '$lib/server/pocketbase';
import { presignAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
/**
* GET /api/comments/[slug]?sort=new|top
* Returns top-level comments + their replies + current visitor's votes + avatar URLs.
* Response: { comments: BookComment[], myVotes: Record<string, 'up'|'down'>, avatarUrls: Record<string, string> }
* Each top-level comment has a `replies` array attached.
*/
export const GET: RequestHandler = async ({ params, url, locals }) => {
const { slug } = params;
const sortParam = url.searchParams.get('sort') ?? 'new';
const sort: CommentSort = sortParam === 'top' ? 'top' : 'new';
try {
const topLevel = await listComments(slug, sort);
// Fetch replies for all top-level comments in parallel
const repliesPerComment = await Promise.all(topLevel.map((c) => listReplies(c.id)));
const allReplies = repliesPerComment.flat();
// Build comment+reply list for vote lookup
const allIds = [...topLevel.map((c) => c.id), ...allReplies.map((r) => r.id)];
const myVotes = await getMyVotes(allIds, locals.sessionId, locals.user?.id);
// Attach replies to each top-level comment
const comments = topLevel.map((c, i) => ({
...c,
replies: repliesPerComment[i]
}));
// Batch-resolve avatar presign URLs for all unique user_ids
const allComments = [...topLevel, ...allReplies];
const uniqueUserIds = [...new Set(allComments.map((c) => c.user_id).filter(Boolean))];
const avatarEntries = await Promise.all(
uniqueUserIds.map(async (userId) => {
try {
const url = await presignAvatarUrl(userId);
return [userId, url] as [string, string | null];
} catch {
return [userId, null] as [string, null];
}
})
);
const avatarUrls: Record<string, string> = {};
for (const [userId, url] of avatarEntries) {
if (url) avatarUrls[userId] = url;
}
return json({ comments, myVotes, avatarUrls });
} catch (e) {
log.error('api/comments/[slug]', 'listComments failed', { slug, err: String(e) });
error(500, 'Failed to load comments');
}
};
/**
* POST /api/comments/[slug]
* Body: { body: string, parent_id?: string }
* Creates a new comment or reply. Requires authentication.
*/
export const POST: RequestHandler = async ({ params, request, locals }) => {
if (!locals.user) error(401, 'Login required to comment');
const { slug } = params;
let body: { body?: string; parent_id?: string };
try {
body = await request.json();
} catch {
error(400, 'Invalid JSON body');
}
const text = (body.body ?? '').trim();
if (!text) error(400, 'Comment body is required');
if (text.length > 2000) error(400, 'Comment is too long (max 2000 characters)');
// Enforce 1-level depth: parent_id must be a top-level comment
const parentId = body.parent_id?.trim() || undefined;
try {
const comment = await createComment(
slug,
text,
locals.user.id,
locals.user.username,
parentId
);
return json(comment, { status: 201 });
} catch (e) {
log.error('api/comments/[slug]', 'createComment failed', { slug, err: String(e) });
error(500, 'Failed to post comment');
}
};

View File

@@ -0,0 +1,65 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
listBooks,
recentlyAddedBooks,
allProgress,
getHomeStats,
getSubscriptionFeed
} from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import type { Book, Progress } from '$lib/server/pocketbase';
/**
* GET /api/home
* Returns home screen data: continue-reading list, recently updated books, stats,
* and subscription feed (books recently read by followed users).
* Requires authentication (enforced by layout guard).
*/
export const GET: RequestHandler = async ({ locals }) => {
let allBooks: Book[] = [];
let recentBooks: Book[] = [];
let progressList: Progress[] = [];
let stats = { totalBooks: 0, totalChapters: 0 };
try {
[allBooks, recentBooks, progressList, stats] = await Promise.all([
listBooks(),
recentlyAddedBooks(8),
allProgress(locals.sessionId, locals.user?.id),
getHomeStats()
]);
} catch (e) {
log.error('api/home', 'failed to load home data', { err: String(e) });
}
const bookMap = new Map<string, Book>(allBooks.map((b) => [b.slug, b]));
const continueReading = progressList
.filter((p) => bookMap.has(p.slug))
.slice(0, 6)
.map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter }));
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
// Subscription feed — only available for logged-in users with following
let subscriptionFeed: Array<{ book: Book; readerUsername: string }> = [];
if (locals.user?.id) {
subscriptionFeed = await getSubscriptionFeed(locals.user.id).catch(() => []);
}
return json({
continue_reading: continueReading,
recently_updated: recentlyUpdated,
stats: {
totalBooks: stats.totalBooks,
totalChapters: stats.totalChapters,
booksInProgress: continueReading.length
},
subscription_feed: subscriptionFeed.map((item) => ({
book: item.book,
readerUsername: item.readerUsername
}))
});
};

View File

@@ -0,0 +1,61 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listBooks, allProgress, getSavedSlugs } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* GET /api/library
* Returns the user's library: books they have started reading or explicitly saved.
* Each item includes the book record, the last chapter read, and saved_at timestamp.
*
* Response shape mirrors LibraryItem in the iOS APIClient.
*/
export const GET: RequestHandler = async ({ locals }) => {
let allBooks: Awaited<ReturnType<typeof listBooks>>;
let progressList: Awaited<ReturnType<typeof allProgress>>;
let savedSlugs: Set<string>;
try {
[allBooks, progressList, savedSlugs] = await Promise.all([
listBooks(),
allProgress(locals.sessionId, locals.user?.id),
getSavedSlugs(locals.sessionId, locals.user?.id)
]);
} catch (e) {
log.error('api/library', 'failed to load library data', { err: String(e) });
allBooks = [];
progressList = [];
savedSlugs = new Set();
}
const progressMap: Record<string, number> = {};
const progressUpdatedMap: Record<string, string> = {};
for (const p of progressList) {
progressMap[p.slug] = p.chapter;
progressUpdatedMap[p.slug] = p.updated;
}
const progressSlugs = new Set(progressList.map((p) => p.slug));
const books = allBooks.filter((b) => progressSlugs.has(b.slug) || savedSlugs.has(b.slug));
const withProgress = books.filter((b) => progressSlugs.has(b.slug));
const savedOnly = books
.filter((b) => !progressSlugs.has(b.slug))
.sort((a, b) => (a.title ?? '').localeCompare(b.title ?? ''));
withProgress.sort((a, b) => {
const ta = progressUpdatedMap[a.slug] ?? '';
const tb = progressUpdatedMap[b.slug] ?? '';
return tb.localeCompare(ta);
});
const ordered = [...withProgress, ...savedOnly];
const items = ordered.map((book) => ({
book,
last_chapter: progressMap[book.slug] ?? null,
saved_at: progressUpdatedMap[book.slug] ?? new Date().toISOString()
}));
return json(items);
};

View File

@@ -0,0 +1,34 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { saveBook, unsaveBook } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* POST /api/library/[slug]
* Save a book to the user's personal library.
*/
export const POST: RequestHandler = async ({ params, locals }) => {
const { slug } = params;
try {
await saveBook(locals.sessionId, slug, locals.user?.id);
} catch (e) {
log.error('library', 'saveBook failed', { slug, err: String(e) });
error(500, 'Failed to save book');
}
return json({ ok: true });
};
/**
* DELETE /api/library/[slug]
* Remove a book from the user's personal library.
*/
export const DELETE: RequestHandler = async ({ params, locals }) => {
const { slug } = params;
try {
await unsaveBook(locals.sessionId, slug, locals.user?.id);
} catch (e) {
log.error('library', 'unsaveBook failed', { slug, err: String(e) });
error(500, 'Failed to remove book');
}
return json({ ok: true });
};

View File

@@ -0,0 +1,47 @@
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';
/**
* 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.
*
* 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') ?? '';
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 = cache.get(cacheKey);
if (cached) {
return json({ url: cached });
}
// Slow path: call backend → MinIO presign.
try {
const presignedUrl = await presignAudio(slug, n, voice || undefined);
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');
}
log.error('presign', 'presign audio failed', { slug, n, err: String(e) });
error(500, `Could not get presigned URL: ${e}`);
}
};

View File

@@ -0,0 +1,40 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { presignVoiceSample } from '$lib/server/minio';
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.
*
* 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');
if (!voice) {
error(400, 'Missing voice parameter');
}
const cacheKey = cache.sampleKey(voice);
// Fast path: return cached URL if still valid.
const cached = cache.get(cacheKey);
if (cached) {
return json({ url: cached });
}
// Slow path: call backend → MinIO presign.
try {
const presignedUrl = await presignVoiceSample(voice);
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');
}
error(502, `Failed to presign voice sample: ${e}`);
}
};

View File

@@ -0,0 +1,81 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { presignAvatarUploadUrl, presignAvatarUrl } from '$lib/server/minio';
import { updateUserAvatarUrl, getUserByUsername } from '$lib/server/pocketbase';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
/**
* POST /api/profile/avatar
* Body: JSON { mime_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
* presigned GET URL for immediate display.
*
* Returns: { avatar_url: string | null }
*/
export const PATCH: 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');
}
if (!key) error(400, 'Missing "key" field');
await updateUserAvatarUrl(locals.user.id, key);
const avatarUrl = await presignAvatarUrl(locals.user.id);
return json({ avatar_url: avatarUrl });
};
/**
* GET /api/profile/avatar
* Returns a presigned GET URL for the current user's avatar, or null if none set.
*/
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) error(401, 'Not authenticated');
const record = await getUserByUsername(locals.user.username).catch(() => null);
if (!record?.avatar_url) {
return json({ avatar_url: null });
}
const avatarUrl = await presignAvatarUrl(locals.user.id);
return json({ avatar_url: avatarUrl });
};

View File

@@ -0,0 +1,27 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { setProgress } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* POST /api/progress
* Body: { slug: string, chapter: number }
* Records the user's reading position.
* When the user is logged in, progress is keyed by user_id so it syncs across devices.
* When anonymous, progress is keyed by the session cookie.
*/
export const POST: RequestHandler = async ({ request, locals }) => {
const body = await request.json().catch(() => null);
if (!body || typeof body.slug !== 'string' || typeof body.chapter !== 'number') {
error(400, 'Invalid body — expected { slug, chapter }');
}
try {
await setProgress(locals.sessionId, body.slug, body.chapter, locals.user?.id);
} catch (e) {
log.error('progress', 'setProgress failed', { slug: body.slug, chapter: body.chapter, err: String(e) });
error(500, 'Failed to save progress');
}
return json({ ok: true });
};

View File

@@ -0,0 +1,54 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { setProgress, deleteProgress } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* POST /api/progress/[slug]
* Body: { chapter: number }
* Records the user's reading position for a specific book.
*
* This is a slug-in-path variant of POST /api/progress (which takes slug in body).
* Used by the iOS app where slug is part of the URL path.
*/
export const POST: RequestHandler = async ({ params, request, locals }) => {
const { slug } = params;
const body = await request.json().catch(() => null);
if (!body || typeof body.chapter !== 'number') {
error(400, 'Invalid body — expected { chapter: number }');
}
try {
await setProgress(locals.sessionId, slug, body.chapter, locals.user?.id);
} catch (e) {
log.error('api/progress/[slug]', 'setProgress failed', {
slug,
chapter: body.chapter,
err: String(e)
});
error(500, 'Failed to save progress');
}
return json({ ok: true });
};
/**
* DELETE /api/progress/[slug]
* Removes reading progress for a specific book (removes from library/continue reading).
*/
export const DELETE: RequestHandler = async ({ params, locals }) => {
const { slug } = params;
try {
await deleteProgress(locals.sessionId, slug, locals.user?.id);
} catch (e) {
log.error('api/progress/[slug]', 'deleteProgress failed', {
slug,
err: String(e)
});
error(500, 'Failed to delete progress');
}
return json({ ok: true });
};

View File

@@ -0,0 +1,56 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { setAudioTime, getAudioTime } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* GET /api/progress/audio-time?slug=&chapter=
* Returns the last saved audio position for a chapter, or null.
*/
export const GET: RequestHandler = async ({ url, locals }) => {
const slug = url.searchParams.get('slug');
const chapterParam = url.searchParams.get('chapter');
if (!slug || !chapterParam) {
error(400, 'Missing slug or chapter query params');
}
const chapter = parseInt(chapterParam, 10);
if (isNaN(chapter)) {
error(400, 'chapter must be a number');
}
try {
const audioTime = await getAudioTime(locals.sessionId, slug, chapter, locals.user?.id);
return json({ audioTime });
} catch (e) {
log.error('audio-time', 'GET failed', { slug, chapter, err: String(e) });
error(500, 'Failed to load audio time');
}
};
/**
* PATCH /api/progress/audio-time
* Body: { slug: string, chapter: number, audioTime: number }
* Saves the current audio playback position.
*/
export const PATCH: RequestHandler = async ({ request, locals }) => {
const body = await request.json().catch(() => null);
if (
!body ||
typeof body.slug !== 'string' ||
typeof body.chapter !== 'number' ||
typeof body.audioTime !== 'number'
) {
error(400, 'Invalid body — expected { slug, chapter, audioTime }');
}
try {
await setAudioTime(locals.sessionId, body.slug, body.chapter, body.audioTime, locals.user?.id);
} catch (e) {
log.error('audio-time', 'PATCH failed', { slug: body.slug, chapter: body.chapter, err: String(e) });
error(500, 'Failed to save audio time');
}
return json({ ok: true });
};

View File

@@ -0,0 +1,27 @@
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/ranking
* Proxies to the Go scraper'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`);
if (!res.ok) {
log.error('api/ranking', 'scraper returned error', { status: res.status });
error(502, `Ranking 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('api/ranking', 'network error', { err: String(e) });
error(502, 'Could not load ranking');
}
};

View File

@@ -0,0 +1,64 @@
/**
* POST /api/scrape
*
* Proxies scrape requests to the Go scraper backend.
* Admin-only — returns 403 if the authenticated user is not an admin.
*
* Request body (JSON):
* { "url": "https://novelfire.net/book/..." } — scrape a single book
* {} — scrape the full catalogue
*
* Responses mirror the Go scraper:
* 202 Accepted — job enqueued
* 409 Conflict — a scrape job is already running
* 400 Bad Request
* 403 Forbidden — not an admin
*/
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';
export const POST: RequestHandler = async ({ request, locals }) => {
// Admin guard
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
let body: { url?: string } = {};
try {
body = await request.json();
} catch {
// empty body is fine — means "scrape all"
}
// Decide which scraper endpoint to call
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, {
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');
}
if (!res.ok && res.status >= 500) {
const text = await res.text().catch(() => '');
log.error('scrape', 'scraper 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, …)
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,42 @@
/**
* 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 { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
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 fetch(`${SCRAPER_URL}/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

@@ -0,0 +1,62 @@
/**
* POST /api/scrape/range
*
* Proxies range-scrape requests to the Go scraper backend at POST /scrape/book/range.
* Admin-only.
*
* Request body (JSON):
* { "url": "https://novelfire.net/book/...", "from": 50, "to": 100 }
* "to" is optional — omit to scrape from "from" to the end.
*
* Responses mirror the Go scraper:
* 202 Accepted — job enqueued
* 409 Conflict — a scrape job is already running
* 400 Bad Request
* 403 Forbidden — not an admin
*/
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';
export const POST: RequestHandler = async ({ request, locals }) => {
// Admin guard
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
let body: { url?: string; from?: number; to?: number } = {};
try {
body = await request.json();
} catch {
throw error(400, 'Invalid JSON body');
}
if (!body.url || typeof body.from !== 'number') {
throw error(400, 'url and from are required');
}
const upstream = `${SCRAPER_URL}/scrape/book/range`;
let res: Response;
try {
res = await fetch(upstream, {
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');
}
if (!res.ok && res.status >= 500) {
const text = await res.text().catch(() => '');
log.error('scrape/range', 'scraper returned error', { status: res.status, body: text });
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,36 @@
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/search?q=<query>
* Proxies to the Go scraper's /api/search endpoint.
* Returns: { results, local_count, remote_count }
*
* Response shape mirrors SearchResponse in the iOS APIClient.
*/
export const GET: RequestHandler = async ({ url }) => {
const q = url.searchParams.get('q') ?? '';
if (q.trim().length < 2) {
return json({ results: [], local_count: 0, remote_count: 0 });
}
const apiURL = `${SCRAPER_URL}/api/search?q=${encodeURIComponent(q.trim())}`;
try {
const res = await fetch(apiURL);
if (!res.ok) {
log.error('api/search', 'scraper returned error', { status: res.status, q });
error(502, `Search failed: ${res.status}`);
}
const data = await res.json();
return json(data);
} 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');
}
};

View File

@@ -0,0 +1,32 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listUserSessions } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* GET /api/sessions
* Returns all active sessions for the logged-in user.
*/
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) {
error(401, 'Not logged in');
}
try {
const sessions = await listUserSessions(locals.user.id);
// Don't expose raw session_id to the client — only the record ID for revocation
const safe = sessions.map((s) => ({
id: s.id,
user_agent: s.user_agent,
ip: s.ip,
created_at: s.created_at,
last_seen: s.last_seen,
// Tell the client whether this is the currently active session
is_current: s.session_id === locals.user!.authSessionId
}));
return json({ sessions: safe });
} catch (e) {
log.error('sessions', 'GET failed', { err: String(e) });
error(500, 'Failed to load sessions');
}
};

View File

@@ -0,0 +1,41 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { revokeUserSession } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* DELETE /api/sessions/[id]
* Revokes a specific session by its PocketBase record ID.
* Only the owner can revoke their own sessions.
*/
export const DELETE: RequestHandler = async ({ params, locals, cookies }) => {
if (!locals.user) {
error(401, 'Not logged in');
}
const recordId = params.id;
if (!recordId) {
error(400, 'Session ID required');
}
try {
const ok = await revokeUserSession(recordId, locals.user.id);
if (!ok) {
error(404, 'Session not found or not yours');
}
// If the user is terminating their own current session, clear their auth cookie
// so they get logged out immediately (the hook would do this on the next request anyway,
// but clearing it here gives instant feedback for the "end this session" flow).
// For other sessions, we leave the cookie intact.
// We detect "current session" via authSessionId — but since the client sends the
// record ID (not the session_id), we rely on the UI to redirect after ending its own session.
log.info('sessions', 'session revoked', { recordId, userId: locals.user.id });
return json({ ok: true });
} catch (e) {
if (e instanceof Error && 'status' in e) throw e; // re-throw SvelteKit errors
log.error('sessions', 'DELETE failed', { recordId, err: String(e) });
error(500, 'Failed to revoke session');
}
};

View File

@@ -0,0 +1,49 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getSettings, saveSettings } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* GET /api/settings
* Returns the current user's settings (auto_next, voice, speed).
* Returns defaults if no settings record exists yet.
*/
export const GET: RequestHandler = async ({ locals }) => {
try {
const settings = await getSettings(locals.sessionId, locals.user?.id);
return json({
autoNext: settings?.auto_next ?? false,
voice: settings?.voice ?? 'af_bella',
speed: settings?.speed ?? 1.0
});
} catch (e) {
log.error('settings', 'GET failed', { err: String(e) });
error(500, 'Failed to load settings');
}
};
/**
* PUT /api/settings
* Body: { autoNext: boolean, voice: string, speed: number }
* Saves user preferences.
*/
export const PUT: RequestHandler = async ({ request, locals }) => {
const body = await request.json().catch(() => null);
if (
!body ||
typeof body.autoNext !== 'boolean' ||
typeof body.voice !== 'string' ||
typeof body.speed !== 'number'
) {
error(400, 'Invalid body — expected { autoNext, voice, speed }');
}
try {
await saveSettings(locals.sessionId, body, locals.user?.id);
} catch (e) {
log.error('settings', 'PUT failed', { err: String(e) });
error(500, 'Failed to save settings');
}
return json({ ok: true });
};

View File

@@ -0,0 +1,46 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPublicProfile, getSubscription } from '$lib/server/pocketbase';
import { presignAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
/**
* GET /api/users/[username]
* Returns public profile info + whether the current user is subscribed.
*/
export const GET: RequestHandler = async ({ params, locals }) => {
const { username } = params;
try {
const profile = await getPublicProfile(username);
if (!profile) error(404, `User "${username}" not found`);
// Resolve avatar presigned URL if set
let avatarUrl: string | null = null;
if (profile.avatar_url) {
avatarUrl = await presignAvatarUrl(profile.id).catch(() => null);
}
// Is the current logged-in user subscribed?
let isSubscribed = false;
if (locals.user && locals.user.id !== profile.id) {
const sub = await getSubscription(locals.user.id, profile.id).catch(() => null);
isSubscribed = !!sub;
}
return json({
id: profile.id,
username: profile.username,
avatarUrl,
created: profile.created,
followerCount: profile.followerCount,
followingCount: profile.followingCount,
isSubscribed,
isSelf: locals.user?.id === profile.id
});
} catch (e) {
if ((e as { status?: number }).status === 404) throw e;
log.error('api/users', 'failed to load profile', { username, err: String(e) });
error(500, 'Failed to load profile');
}
};

View File

@@ -0,0 +1,43 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
getUserByUsername,
getUserPublicLibrary,
getUserCurrentlyReading
} from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* GET /api/users/[username]/library
* Returns the public library + currently-reading list for a user.
* Does not require authentication — all data is public.
*/
export const GET: RequestHandler = async ({ params }) => {
const { username } = params;
const user = await getUserByUsername(username).catch(() => null);
if (!user) error(404, `User "${username}" not found`);
try {
const [currentlyReading, library] = await Promise.all([
getUserCurrentlyReading(user.id),
getUserPublicLibrary(user.id)
]);
return json({
currently_reading: currentlyReading.map((item) => ({
book: item.book,
last_chapter: item.chapter,
saved: false
})),
library: library.map((item) => ({
book: item.book,
last_chapter: item.chapter,
saved: item.saved
}))
});
} catch (e) {
log.error('api/users/library', 'failed to load library', { username, err: String(e) });
error(500, 'Failed to load library');
}
};

View File

@@ -0,0 +1,48 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import {
getUserByUsername,
subscribe,
unsubscribe,
getSubscription
} from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* POST /api/users/[username]/subscribe — subscribe to a user
* DELETE /api/users/[username]/subscribe — unsubscribe
* Requires authentication.
*/
export const POST: RequestHandler = async ({ params, locals }) => {
if (!locals.user) error(401, 'Login required');
const { username } = params;
const target = await getUserByUsername(username).catch(() => null);
if (!target) error(404, `User "${username}" not found`);
if (locals.user.id === target.id) error(400, 'Cannot subscribe to yourself');
try {
await subscribe(locals.user.id, target.id);
const sub = await getSubscription(locals.user.id, target.id);
return json({ subscribed: true, subId: sub?.id ?? null });
} catch (e) {
log.error('api/users/subscribe', 'subscribe failed', { username, err: String(e) });
error(500, 'Failed to subscribe');
}
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
if (!locals.user) error(401, 'Login required');
const { username } = params;
const target = await getUserByUsername(username).catch(() => null);
if (!target) error(404, `User "${username}" not found`);
try {
await unsubscribe(locals.user.id, target.id);
return json({ subscribed: false });
} catch (e) {
log.error('api/users/subscribe', 'unsubscribe failed', { username, err: String(e) });
error(500, 'Failed to unsubscribe');
}
};

View File

@@ -0,0 +1,23 @@
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';
/**
* GET /api/voices
* Proxies the voice list from the scraper → Kokoro.
* Returns { voices: string[] }
*/
export const GET: RequestHandler = async () => {
try {
const res = await fetch(`${SCRAPER_URL}/api/voices`);
if (!res.ok) {
return json({ voices: [] });
}
const data = (await res.json()) as { voices: string[] };
return json({ voices: data.voices ?? [] });
} catch {
return json({ voices: [] });
}
};

View File

@@ -0,0 +1,57 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { listBooks, allProgress, getSavedSlugs } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
export const load: PageServerLoad = async ({ locals }) => {
let allBooks: Awaited<ReturnType<typeof listBooks>>;
let progressList: Awaited<ReturnType<typeof allProgress>>;
let savedSlugs: Set<string>;
try {
[allBooks, progressList, savedSlugs] = await Promise.all([
listBooks(),
allProgress(locals.sessionId, locals.user?.id),
getSavedSlugs(locals.sessionId, locals.user?.id)
]);
} catch (e) {
log.error('books', 'failed to load library data', { err: String(e) });
allBooks = [];
progressList = [];
savedSlugs = new Set();
}
// Build a quick lookup: slug → last chapter read
const progressMap: Record<string, number> = {};
for (const p of progressList) {
progressMap[p.slug] = p.chapter;
}
// Library = books the user has started reading OR explicitly saved
const progressSlugs = new Set(progressList.map((p) => p.slug));
const books = allBooks.filter((b) => progressSlugs.has(b.slug) || savedSlugs.has(b.slug));
// Sort: books with progress first (most-recently-read order is implicit via progressList),
// then saved-only books alphabetically.
const withProgress = books.filter((b) => progressSlugs.has(b.slug));
const savedOnly = books
.filter((b) => !progressSlugs.has(b.slug))
.sort((a, b) => (a.title ?? '').localeCompare(b.title ?? ''));
// Re-sort withProgress by most recent progress update
const progressUpdatedMap: Record<string, string> = {};
for (const p of progressList) {
progressUpdatedMap[p.slug] = p.updated;
}
withProgress.sort((a, b) => {
const ta = progressUpdatedMap[a.slug] ?? '';
const tb = progressUpdatedMap[b.slug] ?? '';
return tb.localeCompare(ta); // descending — most recently read first
});
return {
books: [...withProgress, ...savedOnly],
progressMap,
savedSlugs: [...savedSlugs]
};
};

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
function parseGenres(genres: string[] | string | null | undefined): string[] {
if (!genres) return [];
if (Array.isArray(genres)) return genres;
try {
const parsed = JSON.parse(genres);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
</script>
<svelte:head>
<title>Library — libnovel</title>
</svelte:head>
<div class="mb-6">
<h1 class="text-2xl font-bold text-zinc-100">Library</h1>
<p class="text-zinc-400 text-sm mt-1">
{data.books?.length ?? 0} book{(data.books?.length ?? 0) !== 1 ? 's' : ''}
</p>
</div>
{#if !data.books?.length}
<div class="text-center py-20 text-zinc-500">
<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>
will appear here.
</p>
</div>
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{#each data.books as book}
{@const lastChapter = data.progressMap[book.slug]}
{@const genres = parseGenres(book.genres)}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
>
<!-- Cover image -->
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
</div>
<!-- Info -->
<div class="p-2 flex flex-col gap-1 flex-1">
<h2 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">
{book.title ?? ''}
</h2>
{#if book.author}
<p class="text-xs text-zinc-400 truncate">{book.author ?? ''}</p>
{/if}
<div class="mt-auto pt-1 flex items-center justify-between gap-1">
{#if book.status}
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300 truncate max-w-[60%]">
{book.status}
</span>
{/if}
{#if lastChapter}
<span class="text-xs text-amber-400 font-medium ml-auto whitespace-nowrap">
ch.{lastChapter}
</span>
{/if}
</div>
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-1">
{#each genres.slice(0, 2) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,123 @@
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';
export const load: PageServerLoad = async ({ params, locals }) => {
const { slug } = params;
// Try fetching from PocketBase first
let book = await getBook(slug).catch((e) => {
log.error('books', 'getBook failed', { slug, err: String(e) });
return null;
});
if (book) {
// Book is in the library — normal path
let chapters, progress, saved;
try {
[chapters, progress, saved] = await Promise.all([
listChapterIdx(slug),
getProgress(locals.sessionId, slug, locals.user?.id),
isBookSaved(locals.sessionId, slug, locals.user?.id)
]);
} catch (e) {
log.error('books', 'failed to load book page data', { slug, err: String(e) });
throw error(500, 'Failed to load book');
}
return {
book,
chapters,
inLib: true,
saved,
lastChapter: progress?.chapter ?? null,
isAdmin: locals.user?.role === 'admin',
isLoggedIn: !!locals.user,
currentUserId: locals.user?.id ?? '',
// Not scraping
scraping: false,
taskId: null as string | null
};
}
// Book not in PocketBase — ask backend to enqueue a scrape task.
try {
const res = await fetch(`${SCRAPER_URL}/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`);
}
// 200 — book was already in library when backend checked
const preview: {
in_lib: boolean;
meta: {
slug: string;
title: string;
author: string;
cover: string;
status: string;
genres: string[];
summary: string;
total_chapters: number;
source_url: string;
};
chapters: { number: number; title: string; date?: string }[];
} = await res.json();
const previewBook = {
id: '',
slug: preview.meta.slug || slug,
title: preview.meta.title,
author: preview.meta.author,
cover: preview.meta.cover,
status: preview.meta.status,
genres: preview.meta.genres ?? [],
summary: preview.meta.summary,
total_chapters: preview.meta.total_chapters,
source_url: preview.meta.source_url,
ranking: 0,
meta_updated: ''
};
return {
book: previewBook,
chapters: preview.chapters,
inLib: true,
saved: false,
lastChapter: null,
isAdmin: locals.user?.role === 'admin',
isLoggedIn: !!locals.user,
currentUserId: locals.user?.id ?? '',
scraping: false,
taskId: null as string | null
};
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
log.error('books', 'book-preview fetch failed', { slug, err: String(e) });
error(404, `Book "${slug}" not found`);
}
};

View File

@@ -0,0 +1,420 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { PageData } from './$types';
import CommentsSection from '$lib/components/CommentsSection.svelte';
let { data }: { data: PageData } = $props();
// ── Save / unsave ─────────────────────────────────────────────────────────
let saved = $state(untrack(() => data.saved));
let saving = $state(false);
async function toggleSave() {
if (saving || !data.book) return;
saving = true;
try {
const method = saved ? 'DELETE' : 'POST';
const res = await fetch(`/api/library/${encodeURIComponent(data.book?.slug ?? '')}`, { method });
if (res.ok) saved = !saved;
} finally {
saving = false;
}
}
function parseGenres(genres: string[] | string): string[] {
if (Array.isArray(genres)) return genres;
try {
return JSON.parse(genres);
} catch {
return [];
}
}
const genres = $derived(parseGenres(data.book?.genres ?? []));
// 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;
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 })
});
if (res.ok) scrapeResult = 'queued';
else if (res.status === 409) scrapeResult = 'busy';
else scrapeResult = 'error';
} catch {
scrapeResult = 'error';
} finally {
scraping = false;
}
}
// ── Admin: scrape range ───────────────────────────────────────────────────
let rangeFrom = $state('');
let rangeTo = $state('');
let rangeScraping = $state(false);
let rangeResult = $state<'queued' | 'busy' | 'error' | ''>('');
async function scrapeRange() {
if (rangeScraping || !data.book?.source_url) return;
const from = parseInt(rangeFrom, 10);
const to = parseInt(rangeTo, 10);
if (!from || from < 1) return;
rangeScraping = true;
rangeResult = '';
try {
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 })
});
if (res.ok) rangeResult = 'queued';
else if (res.status === 409) rangeResult = 'busy';
else rangeResult = 'error';
} catch {
rangeResult = 'error';
} finally {
rangeScraping = false;
}
}
// ── Summary expand/collapse ───────────────────────────────────────────────
let summaryExpanded = $state(false);
// ── Admin panel expand/collapse ───────────────────────────────────────────
let adminOpen = $state(false);
</script>
<svelte:head>
<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. Refresh the page in a minute.
</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 book.cover}
<div
class="absolute inset-0 bg-cover bg-center scale-110"
style="background-image: url('{book.cover}'); filter: blur(24px); opacity: 0.18;"
aria-hidden="true"
></div>
{/if}
<div class="absolute inset-0 bg-gradient-to-b from-zinc-900/60 to-zinc-900/95 pointer-events-none" aria-hidden="true"></div>
<div class="relative flex flex-col p-5 sm:p-7 gap-4">
<!-- Cover + meta row -->
<div class="flex gap-5 sm:gap-8">
<!-- Cover image -->
{#if book.cover}
<img
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}
<!-- Meta -->
<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">{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"
title="This book was fetched live from the source and is not yet in your library"
>
not in library
</span>
{/if}
</div>
<!-- Author -->
{#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 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>
{/each}
</div>
<!-- Summary with expand toggle -->
{#if book.summary}
<div class="mt-1">
<p class="text-zinc-400 text-sm leading-relaxed break-words {summaryExpanded ? '' : 'line-clamp-3'}">
{book.summary}
</p>
{#if book.summary.length > 220}
<button
onclick={() => (summaryExpanded = !summaryExpanded)}
class="text-xs text-amber-400/70 hover:text-amber-400 mt-1 transition-colors"
>
{summaryExpanded ? 'Less' : 'More'}
</button>
{/if}
</div>
{/if}
<!-- CTA buttons — desktop only (hidden on mobile, shown below on mobile) -->
<div class="hidden sm:flex gap-2 mt-3 items-center flex-wrap">
{#if data.lastChapter}
<a
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}
</a>
{/if}
{#if chapterList.length > 0}
<a
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'
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300 shadow'}"
>
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
</a>
{/if}
{#if data.inLib}
<button
onclick={toggleSave}
disabled={saving}
title={saved ? 'Remove from library' : 'Add to library'}
class="flex items-center justify-center w-9 h-9 rounded-lg border transition-colors disabled:opacity-50
{saved
? 'bg-amber-400/20 text-amber-300 border-amber-400/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
: 'bg-zinc-700 text-zinc-400 border-zinc-600 hover:bg-zinc-600 hover:text-zinc-100'}"
>
{#if saving}
<svg class="w-4 h-4 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>
{:else if saved}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{:else}
<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="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{/if}
</button>
{/if}
</div>
</div>
</div>
<!-- CTA buttons — mobile only, full-width row below cover+meta -->
<div class="flex sm:hidden gap-2 items-center">
{#if data.lastChapter}
<a
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}
</a>
{/if}
{#if chapterList.length > 0}
<a
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'
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300 shadow'}"
>
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
</a>
{/if}
{#if data.inLib}
<button
onclick={toggleSave}
disabled={saving}
title={saved ? 'Remove from library' : 'Add to library'}
class="flex items-center justify-center w-10 h-10 flex-shrink-0 rounded-lg border transition-colors disabled:opacity-50
{saved
? 'bg-amber-400/20 text-amber-300 border-amber-400/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
: 'bg-zinc-700 text-zinc-400 border-zinc-600 hover:bg-zinc-600 hover:text-zinc-100'}"
>
{#if saving}
<svg class="w-4 h-4 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>
{:else if saved}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{:else}
<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="M5 4a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 20V4z"/>
</svg>
{/if}
</button>
{/if}
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════ Chapters row ══ -->
<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/{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">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h10"/>
</svg>
<div class="flex flex-col min-w-0 flex-1">
<span class="text-sm font-semibold text-zinc-200">Chapters</span>
{#if chapterList.length > 0}
<span class="text-xs text-zinc-500">
{#if data.lastChapter && data.lastChapter > 0}
Reading ch.{data.lastChapter} of {chapterList.length}
{:else}
{chapterList.length} chapter{chapterList.length === 1 ? '' : 's'}
{/if}
</span>
{/if}
</div>
<svg class="w-4 h-4 text-zinc-600 group-hover:text-zinc-400 transition-colors flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</a>
<!-- Admin panel (collapsed by default, admin only) -->
{#if data.isAdmin && book.source_url}
<div>
<button
onclick={() => (adminOpen = !adminOpen)}
class="w-full flex items-center gap-2 px-4 py-2.5 text-xs font-medium text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/50 transition-colors text-left"
>
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
Admin
<svg class="w-3 h-3 ml-auto transition-transform {adminOpen ? 'rotate-180' : ''}" 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>
{#if adminOpen}
<div class="px-4 py-3 border-t border-zinc-800 flex flex-col gap-4">
<!-- Rescrape -->
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={rescrape}
disabled={scraping}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors
{scraping ? 'bg-zinc-700 text-zinc-500 cursor-not-allowed' : 'bg-zinc-700 text-zinc-200 hover:bg-zinc-600'}"
>
{#if scraping}
<svg class="w-3 h-3 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>
Queuing…
{:else}
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Rescrape book
{/if}
</button>
{#if scrapeResult}
<span class="text-xs {scrapeResult === 'queued' ? 'text-green-400' : scrapeResult === 'busy' ? 'text-amber-400' : 'text-red-400'}">
{scrapeResult === 'queued' ? 'Queued.' : scrapeResult === 'busy' ? 'Scraper busy.' : 'Error.'}
</span>
{/if}
</div>
<!-- Range scrape -->
<div class="flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1">
<label for="range-from" class="text-xs text-zinc-500">From chapter</label>
<input
id="range-from"
type="number"
min="1"
bind:value={rangeFrom}
placeholder="1"
class="w-24 px-2 py-1 rounded bg-zinc-700 border border-zinc-600 text-zinc-200 text-xs focus:outline-none focus:border-amber-400"
/>
</div>
<div class="flex flex-col gap-1">
<label for="range-to" class="text-xs text-zinc-500">To chapter (optional)</label>
<input
id="range-to"
type="number"
min="1"
bind:value={rangeTo}
placeholder="end"
class="w-24 px-2 py-1 rounded bg-zinc-700 border border-zinc-600 text-zinc-200 text-xs focus:outline-none focus:border-amber-400"
/>
</div>
<button
onclick={scrapeRange}
disabled={rangeScraping || !rangeFrom}
class="px-3 py-1.5 rounded text-xs font-medium transition-colors
{rangeScraping || !rangeFrom
? 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
: 'bg-amber-500/20 text-amber-300 hover:bg-amber-500/40 border border-amber-500/30'}"
>
{rangeScraping ? 'Queuing…' : 'Scrape range'}
</button>
{#if rangeResult}
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-amber-400' : 'text-red-400'}">
{rangeResult === 'queued' ? 'Range scrape queued.' : rangeResult === 'busy' ? 'Scraper busy.' : 'Error queuing.'}
</span>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>
<!-- ══════════════════════════════════════════════════ Comments ══ -->
<CommentsSection slug={book.slug} isLoggedIn={data.isLoggedIn} currentUserId={data.currentUserId} />
{/if}

View File

@@ -0,0 +1,32 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getBook, listChapterIdx, getProgress } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
export const load: PageServerLoad = async ({ params, locals }) => {
const { slug } = params;
const book = await getBook(slug).catch((e) => {
log.error('chapters', 'getBook failed', { slug, err: String(e) });
return null;
});
if (!book) error(404, `Book "${slug}" not found`);
let chapters, progress;
try {
[chapters, progress] = await Promise.all([
listChapterIdx(slug),
getProgress(locals.sessionId, slug, locals.user?.id)
]);
} catch (e) {
log.error('chapters', 'failed to load chapters', { slug, err: String(e) });
throw error(500, 'Failed to load chapters');
}
return {
book: { slug: book.slug, title: book.title, cover: book.cover ?? '', totalChapters: book.total_chapters },
chapters,
lastChapter: progress?.chapter ?? null
};
};

View File

@@ -0,0 +1,203 @@
<script lang="ts">
import type { PageData } from './$types';
import type { ChapterIdx } from '$lib/server/pocketbase';
let { data }: { data: PageData } = $props();
const PAGE_SIZE = 100;
// ── Search ──────────────────────────────────────────────────────────────────
let searchQuery = $state('');
const filtered = $derived(
(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return data.chapters;
return data.chapters.filter(
(c: ChapterIdx) =>
String(c.number).includes(q) ||
c.title.toLowerCase().includes(q)
);
})()
);
// ── Page groups (only shown when not searching) ──────────────────────────
const totalGroups = $derived(Math.ceil(data.chapters.length / PAGE_SIZE));
// Which group the current chapter is in (0-indexed)
const currentGroup = $derived(
data.lastChapter
? Math.floor(
(data.chapters.findIndex((c: ChapterIdx) => c.number === data.lastChapter)) /
PAGE_SIZE
)
: 0
);
let activeGroup = $state(0);
// On mount, jump to the group containing the current chapter
$effect(() => {
if (data.lastChapter && currentGroup >= 0) {
activeGroup = currentGroup;
}
});
const visibleChapters = $derived(
searchQuery.trim()
? filtered
: data.chapters.slice(activeGroup * PAGE_SIZE, (activeGroup + 1) * PAGE_SIZE)
);
function groupLabel(i: number): string {
const from = i * PAGE_SIZE + 1;
const to = Math.min((i + 1) * PAGE_SIZE, data.chapters.length);
return `${from}${to}`;
}
</script>
<svelte:head>
<title>{data.book.title} — Chapters — libnovel</title>
</svelte:head>
<!-- ── Back link + title ─────────────────────────────────────────────────── -->
<div class="flex items-center gap-3 mb-5">
<a
href="/books/{data.book.slug}"
class="flex items-center gap-1.5 text-zinc-400 hover:text-zinc-200 transition-colors text-sm"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
</svg>
Back
</a>
<span class="text-zinc-700">/</span>
<h1 class="text-base font-semibold text-zinc-200 truncate">{data.book.title}</h1>
</div>
<!-- ── Search bar ───────────────────────────────────────────────────────── -->
<div class="relative mb-4">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500 pointer-events-none" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8"/><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35"/>
</svg>
<input
type="search"
placeholder="Search chapters…"
bind:value={searchQuery}
class="w-full pl-9 pr-4 py-2.5 rounded-lg bg-zinc-800 border border-zinc-700 text-zinc-200 placeholder-zinc-500 text-sm focus:outline-none focus:border-amber-400 transition-colors"
/>
{#if searchQuery}
<button
onclick={() => (searchQuery = '')}
class="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
aria-label="Clear search"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
{/if}
</div>
<!-- ── Page-group selector (hidden while searching) ──────────────────────── -->
{#if !searchQuery && totalGroups > 1}
<div class="flex flex-wrap gap-1.5 mb-4">
{#each Array(totalGroups) as _, i}
<button
onclick={() => (activeGroup = i)}
class="px-2.5 py-1 rounded text-xs font-medium transition-colors
{activeGroup === i
? 'bg-amber-400 text-zinc-900'
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'}
{currentGroup === i && activeGroup !== i ? 'ring-1 ring-amber-400/50' : ''}"
>
{groupLabel(i)}
</button>
{/each}
</div>
{/if}
<!-- ── Jump-to-current banner ──────────────────────────────────────────── -->
{#if data.lastChapter && data.lastChapter > 0 && !searchQuery && activeGroup !== currentGroup}
<button
onclick={() => (activeGroup = currentGroup)}
class="flex items-center gap-2 w-full px-3 py-2 mb-3 rounded-lg bg-amber-400/10 border border-amber-400/25 text-amber-400 text-sm hover:bg-amber-400/20 transition-colors"
>
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
Jump to Ch.{data.lastChapter}
</button>
{/if}
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
{#if visibleChapters.length === 0}
{#if searchQuery}
<p class="text-zinc-500 text-sm py-8 text-center">No chapters match "{searchQuery}"</p>
{:else}
<p class="text-zinc-500 text-sm">No chapters available yet.</p>
{/if}
{:else}
<!-- Result count while searching -->
{#if searchQuery}
<p class="text-xs text-zinc-500 mb-2">{visibleChapters.length} result{visibleChapters.length === 1 ? '' : 's'}</p>
{/if}
<div class="flex flex-col gap-0.5">
{#each visibleChapters as chapter}
{@const isCurrent = data.lastChapter === chapter.number}
<a
href="/books/{data.book.slug}/chapters/{chapter.number}"
id="ch-{chapter.number}"
class="flex items-center gap-3 px-3 py-2.5 rounded transition-colors group
{isCurrent ? 'bg-zinc-800' : 'hover:bg-zinc-800/60'}"
>
<!-- Number badge -->
<span
class="w-9 text-right text-sm font-mono flex-shrink-0
{isCurrent ? 'text-amber-400 font-semibold' : 'text-zinc-600'}"
>
{chapter.number}
</span>
<!-- Title -->
<span
class="flex-1 min-w-0 text-sm truncate transition-colors
{isCurrent ? 'text-amber-300 font-medium' : 'text-zinc-300 group-hover:text-zinc-100'}"
>
{chapter.title || `Chapter ${chapter.number}`}
</span>
<!-- Date — desktop only -->
{#if chapter.date_label}
<span class="hidden sm:block text-xs text-zinc-600 flex-shrink-0">
{chapter.date_label}
</span>
{/if}
<!-- Reading indicator -->
{#if isCurrent}
<span class="text-xs text-amber-500 font-medium flex-shrink-0">reading</span>
{/if}
</a>
{/each}
</div>
<!-- Bottom page-group nav (mirrors top, for long lists) -->
{#if !searchQuery && totalGroups > 1}
<div class="flex flex-wrap gap-1.5 mt-5 pt-4 border-t border-zinc-800">
{#each Array(totalGroups) as _, i}
<button
onclick={() => { activeGroup = i; window.scrollTo({ top: 0, behavior: 'smooth' }); }}
class="px-2.5 py-1 rounded text-xs font-medium transition-colors
{activeGroup === i
? 'bg-amber-400 text-zinc-900'
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200'}
{currentGroup === i && activeGroup !== i ? 'ring-1 ring-amber-400/50' : ''}"
>
{groupLabel(i)}
</button>
{/each}
</div>
{/if}
{/if}

View File

@@ -0,0 +1,140 @@
import { error } from '@sveltejs/kit';
import { marked } from 'marked';
import type { PageServerLoad } from './$types';
import { getBook, listChapterIdx } 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';
export const load: PageServerLoad = async ({ params, url, locals }) => {
const { slug } = params;
const n = parseInt(params.n, 10);
if (!n || n < 1) error(400, 'Invalid chapter number');
const isPreview = url.searchParams.get('preview') === '1';
const chapterUrl = url.searchParams.get('chapter_url') ?? '';
const chapterTitle = url.searchParams.get('title') ?? '';
if (isPreview) {
// ── Preview path: scrape chapter live, nothing from PocketBase/MinIO ──
const previewParams = new URLSearchParams();
if (chapterUrl) previewParams.set('chapter_url', chapterUrl);
if (chapterTitle) previewParams.set('title', chapterTitle);
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()}`
);
if (!res.ok) {
log.error('chapter', 'chapter-text-preview returned error', { slug, n, status: res.status });
error(404, `Chapter ${n} not found`);
}
chapterData = await res.json();
} catch (e) {
if (e instanceof Error && 'status' in e) throw e;
log.error('chapter', 'chapter-text-preview fetch failed', { slug, n, err: String(e) });
error(502, 'Could not fetch chapter preview');
}
// Wrap plain text in minimal HTML paragraphs for display
const html = chapterData.text
? '<p>' + chapterData.text.replace(/\n{2,}/g, '</p><p>').replace(/\n/g, '<br>') + '</p>'
: '';
// Fetch voices (non-critical for preview)
let voices: string[] = [];
try {
const vRes = await fetch(`${SCRAPER_URL}/api/voices`);
if (vRes.ok) {
const d = (await vRes.json()) as { voices: string[] };
voices = d.voices ?? [];
}
} catch {
// Non-critical
}
// Try to get book title/cover from PocketBase for breadcrumbs; fall back to slug
const pb = await getBook(slug).catch(() => null);
return {
book: {
slug,
title: pb?.title ?? slug,
cover: pb?.cover ?? ''
},
chapter: {
id: '',
slug,
number: n,
title: chapterData.title || `Chapter ${n}`,
date_label: ''
},
html,
voices,
prev: null as number | null,
next: null as number | null,
chapters: [] as { number: number; title: string }[],
sessionId: locals.sessionId,
isPreview: true
};
}
// ── Normal path: fetch from PocketBase + MinIO ─────────────────────────
// Fetch book metadata, chapter index, and voice list in parallel
const [book, chapters, voicesRes] = await Promise.all([
getBook(slug),
listChapterIdx(slug),
fetch(`${SCRAPER_URL}/api/voices`).catch(() => null)
]);
if (!book) error(404, `Book "${slug}" not found`);
const chapterIdx = chapters.find((c) => c.number === n);
if (!chapterIdx) error(404, `Chapter ${n} not found`);
// Parse voices — fall back to a minimal default list on error
let voices: string[] = [];
try {
if (voicesRes?.ok) {
const data = (await voicesRes.json()) as { voices: string[] };
voices = data.voices ?? [];
}
} catch {
// Non-critical — UI will use store default
}
// Fetch chapter markdown directly from the scraper (server-side MinIO read)
let html = '';
try {
const res = await fetch(`${SCRAPER_URL}/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;
const nextChapter = chapters.find((c) => c.number === n + 1) ?? null;
return {
book: { slug: book.slug, title: book.title, cover: book.cover ?? '' },
chapter: chapterIdx,
html,
voices,
prev: prevChapter ? prevChapter.number : null,
next: nextChapter ? nextChapter.number : null,
chapters: chapters.map((c) => ({ number: c.number, title: c.title })),
sessionId: locals.sessionId,
isPreview: false
};
};

View File

@@ -0,0 +1,161 @@
<script lang="ts">
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(untrack(() => data.html));
let fetchingContent = $state(untrack(() => !data.isPreview && !data.html));
let fetchError = $state('');
// ── Word count ────────────────────────────────────────────────────────────
function countWords(htmlStr: string | null): number {
if (!htmlStr) return 0;
// Strip HTML tags, collapse whitespace, split on whitespace
return htmlStr.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).filter(Boolean).length;
}
const wordCount = $derived(countWords(html));
onMount(async () => {
// Record reading progress (skip for preview chapters)
if (!data.isPreview) {
try {
await fetch('/api/progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: data.book.slug, chapter: data.chapter.number })
});
} catch {
// Non-critical — silently ignore
}
}
// If the normal path returned no content, fall back to live preview scrape
if (!data.isPreview && !data.html) {
try {
const res = await fetch(
`/api/chapter-text-preview/${encodeURIComponent(data.book.slug)}/${data.chapter.number}`
);
if (!res.ok) throw new Error(`status ${res.status}`);
const d = (await res.json()) as { text?: string };
if (d.text) {
const { marked } = await import('marked');
html = await marked(d.text, { async: true });
} else {
fetchError = 'Chapter content not available.';
}
} catch (e) {
fetchError = 'Could not fetch chapter content.';
} finally {
fetchingContent = false;
}
}
});
</script>
<svelte:head>
<title>{data.chapter.title || `Chapter ${data.chapter.number}`} — {data.book.title} — libnovel</title>
</svelte:head>
<!-- Top nav -->
<div class="flex items-center justify-between mb-6 gap-4">
<a
href="/books/{data.book.slug}"
class="text-zinc-400 hover:text-zinc-100 text-sm flex items-center gap-1 transition-colors"
>
<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="M15 19l-7-7 7-7" />
</svg>
Chapters
</a>
<div class="flex gap-2">
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="px-3 py-1.5 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors"
>
&larr; Ch.{data.prev}
</a>
{/if}
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="px-3 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
>
Ch.{data.next} &rarr;
</a>
{/if}
</div>
</div>
<!-- Chapter heading -->
<div class="mb-6">
<h1 class="text-xl font-bold text-zinc-100">
{data.chapter.title || `Chapter ${data.chapter.number}`}
</h1>
{#if wordCount > 0}
<p class="text-zinc-600 text-xs mt-1">{wordCount.toLocaleString()} words</p>
{/if}
</div>
<!-- Audio player -->
{#if !data.isPreview}
<AudioPlayer
slug={data.book.slug}
chapter={data.chapter.number}
chapterTitle={data.chapter.title || `Chapter ${data.chapter.number}`}
bookTitle={data.book.title}
cover={data.book.cover}
nextChapter={data.next}
chapters={data.chapters}
voices={data.voices}
/>
{:else}
<div class="mb-6 px-4 py-3 rounded bg-zinc-800/60 border border-zinc-700 text-zinc-500 text-sm">
Preview chapter — audio not available for books outside the library.
</div>
{/if}
<!-- Chapter content -->
{#if fetchingContent}
<div class="flex flex-col items-center gap-3 py-16 text-zinc-500 text-sm">
<svg class="w-6 h-6 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>
Fetching chapter…
</div>
{:else if !html}
<div class="text-zinc-500 text-center py-16">
<p>{fetchError || 'Chapter content not available.'}</p>
</div>
{:else}
<div class="prose-chapter mt-8">
{@html html}
</div>
{/if}
<!-- Bottom nav -->
<div class="flex justify-between mt-12 pt-6 border-t border-zinc-800 gap-4">
{#if data.prev}
<a
href="/books/{data.book.slug}/chapters/{data.prev}"
class="px-4 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors"
>
&larr; Previous chapter
</a>
{:else}
<div></div>
{/if}
{#if data.next}
<a
href="/books/{data.book.slug}/chapters/{data.next}"
class="px-4 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
>
Next chapter &rarr;
</a>
{/if}
</div>

View File

@@ -0,0 +1,170 @@
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,700 @@
<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';
let { data, form }: { data: PageData; form: ActionData } = $props();
// Track which novel card is currently being navigated to
let loadingSlug = $state<string | null>(null);
// Clear loading state when navigation ends (success or failure)
$effect(() => {
if (!navigating) loadingSlug = null;
});
function handleNovelClick(slug: string) {
loadingSlug = slug;
}
// ── 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[]>(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
// to the fresh SSR data (SvelteKit already re-ran the server load).
let filterKey = $derived(`${data.sort}|${data.genre}|${data.status}|${data.searchQuery}`);
let lastFilterKey = '';
$effect(() => {
if (filterKey !== lastFilterKey) {
lastFilterKey = filterKey;
novels = data.novels;
currentPage = data.page;
hasNext = data.hasNext;
}
});
async function loadNextPage() {
if (loadingMore || !hasNext) return;
// Infinite scroll only applies in browse mode (not rank, not search)
if (data.sort === 'rank' || data.searchQuery) return;
loadingMore = true;
const nextPage = currentPage + 1;
try {
const params = new URLSearchParams({
page: String(nextPage),
genre: data.genre,
sort: data.sort,
status: data.status
});
const res = await fetch(`/api/browse-page?${params.toString()}`);
if (!res.ok) return;
const body: { novels: NovelListing[]; page: number; hasNext: boolean } = await res.json();
novels = [...novels, ...(body.novels ?? [])];
currentPage = body.page ?? nextPage;
hasNext = body.hasNext ?? false;
} catch {
// silently ignore — user can scroll again to retry
} finally {
loadingMore = false;
}
}
// ── IntersectionObserver sentinel ────────────────────────────────────────
let sentinel = $state<HTMLDivElement | null>(null);
$effect(() => {
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadNextPage();
},
{ rootMargin: '300px' }
);
observer.observe(sentinel);
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' }
];
const sorts = [
{ value: 'popular', label: 'Popular' },
{ value: 'new', label: 'New' },
{ value: 'update', label: 'Updated' },
{ value: 'rank', label: 'Ranking' }
];
const statuses = [
{ value: 'all', label: 'All' },
{ value: 'ongoing', label: 'Ongoing' },
{ value: 'completed', label: 'Completed' }
];
// When sort=rank the ranking API is used — pagination + genre/status filters
// don't apply to that endpoint.
const isRankView = $derived(data.sort === 'rank');
const isSearchView = $derived(!!data.searchQuery);
// View toggle: 'grid' | 'list'. Persisted in localStorage.
// Rank view always uses list; otherwise restore saved preference (default: grid).
const VIEW_KEY = 'libnovel:browse:view';
function savedView(): 'grid' | 'list' {
if (data.sort === 'rank') return 'list';
if (typeof localStorage !== 'undefined') {
const v = localStorage.getItem(VIEW_KEY);
if (v === 'grid' || v === 'list') return v;
}
return 'grid';
}
let view = $state<'grid' | 'list'>(savedView());
// Keep view in sync when sort changes via filter form, and persist changes.
$effect(() => {
if (data.sort === 'rank' && view === 'grid') view = 'list';
});
$effect(() => {
if (typeof localStorage !== 'undefined' && data.sort !== 'rank') {
localStorage.setItem(VIEW_KEY, view);
}
});
// Admin: per-novel scrape state (grid view)
let scraping: Record<string, boolean> = $state({});
let scrapeResult: Record<string, string> = $state({});
async function scrapeNovel(novel: NovelListing) {
scraping[novel.slug] = true;
scrapeResult[novel.slug] = '';
try {
const res = await fetch('/api/scrape', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: novel.url })
});
if (res.ok) scrapeResult[novel.slug] = 'queued';
else if (res.status === 409) scrapeResult[novel.slug] = 'busy';
else if (res.status === 403) scrapeResult[novel.slug] = 'forbidden';
else scrapeResult[novel.slug] = 'error';
} catch {
scrapeResult[novel.slug] = 'error';
} finally {
scraping[novel.slug] = false;
}
}
// Admin: refresh catalogue
let refreshing = $state(false);
// ── Collapsible filters panel ────────────────────────────────────────────
let filtersOpen = $state(false);
// Human-readable summary of active filters shown on the toggle button
const filterSummary = $derived(() => {
const parts: string[] = [];
const sortLabel = sorts.find((s) => s.value === data.sort)?.label ?? data.sort;
parts.push(sortLabel);
if (data.genre && data.genre !== 'all') {
const genreLabel = genres.find((g) => g.value === data.genre)?.label ?? data.genre;
parts.push(genreLabel);
}
if (data.status && data.status !== 'all') {
const statusLabel = statuses.find((s) => s.value === data.status)?.label ?? data.status;
parts.push(statusLabel);
}
return parts.join(' · ');
});
// Whether any non-default filter is active (used to show a dot indicator)
const hasActiveFilters = $derived(
(data.genre && data.genre !== 'all') ||
(data.status && data.status !== 'all') ||
(data.sort && data.sort !== 'popular')
);
// ── Scroll-to-top button ─────────────────────────────────────────────────
let showScrollTop = $state(false);
$effect(() => {
function onScroll() {
showScrollTop = window.scrollY > 400;
}
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
});
</script>
<svelte:head>
<title>Discover — libnovel</title>
</svelte:head>
<!-- Header -->
<div class="mb-4">
<h1 class="text-2xl font-bold text-zinc-100">Discover</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>"
{#if data.searchLocalCount > 0 || data.searchRemoteCount > 0}
<span class="text-zinc-500 text-xs ml-1">({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire)</span>
{/if}
{:else if isRankView}
{#if novels.length > 0}
{novels.length} novels ranked from last catalogue scrape
{:else}
No ranking data — run a full catalogue scrape to populate
{/if}
{:else}
Browse novels from novelfire.net
{/if}
</p>
</div>
<!-- Admin flash messages -->
{#if form}
{#if form.status === 'queued'}
<div class="mb-4 px-4 py-3 rounded bg-emerald-900/40 border border-emerald-700 text-emerald-300 text-sm">
Full catalogue scrape queued. Library and ranking will update as books are processed.
</div>
{:else if form.status === 'busy'}
<div class="mb-4 px-4 py-3 rounded bg-yellow-900/40 border border-yellow-700 text-yellow-300 text-sm">
A scrape job is already running. Check back once it finishes.
</div>
{:else if form.status === 'error'}
<div class="mb-4 px-4 py-3 rounded bg-red-900/40 border border-red-700 text-red-300 text-sm">
Failed to queue scrape. Check that the scraper service is reachable.
</div>
{/if}
{/if}
<!-- 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">
<input
type="search"
name="q"
value={data.searchQuery}
placeholder="Search…"
class="flex-1 min-w-0 bg-zinc-800 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 placeholder-zinc-500"
/>
<button
type="submit"
class="px-3 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors whitespace-nowrap"
>
Search
</button>
{#if data.searchQuery}
<a
href="/browse"
class="px-3 py-2 rounded bg-zinc-700 text-zinc-300 text-sm hover:bg-zinc-600 transition-colors whitespace-nowrap"
>
Clear
</a>
{/if}
</form>
<!-- Filters toggle button -->
<button
type="button"
onclick={() => (filtersOpen = !filtersOpen)}
aria-expanded={filtersOpen}
class="relative flex items-center gap-1.5 px-3 py-2 rounded border text-sm font-medium transition-colors whitespace-nowrap
{filtersOpen
? 'bg-zinc-700 border-zinc-500 text-zinc-100'
: 'bg-zinc-800 border-zinc-700 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100'}"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 4h18M7 8h10M11 12h2M9 16h6" />
</svg>
<span class="hidden sm:inline">Filters</span>
<!-- Active indicator dot -->
{#if hasActiveFilters}
<span class="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-amber-400"></span>
{/if}
</button>
<!-- View toggle -->
<div class="flex items-center bg-zinc-800 border border-zinc-700 rounded overflow-hidden shrink-0">
<button
onclick={() => (view = 'grid')}
title="Grid view"
class="px-2.5 py-2 transition-colors {view === 'grid'
? 'bg-zinc-600 text-zinc-100'
: 'text-zinc-400 hover:text-zinc-200'}"
>
<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="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
<button
onclick={() => (view = 'list')}
title="List view"
class="px-2.5 py-2 transition-colors {view === 'list'
? 'bg-zinc-600 text-zinc-100'
: 'text-zinc-400 hover:text-zinc-200'}"
>
<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="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
<!-- Admin: refresh catalogue -->
{#if data.isAdmin}
<form
method="POST"
action="?/refresh"
use:enhance={() => {
refreshing = true;
return async ({ update }) => {
await update();
refreshing = false;
};
}}
>
<button
type="submit"
disabled={refreshing}
class="hidden sm:block px-3 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
{refreshing ? 'Queuing…' : 'Refresh'}
</button>
</form>
{/if}
</div>
<!-- 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>
</p>
{/if}
<!-- Collapsible filter panel -->
{#if filtersOpen}
<!-- Admin refresh (mobile only — outside filter form to avoid nested <form>) -->
{#if data.isAdmin}
<form
method="POST"
action="?/refresh"
use:enhance={() => {
refreshing = true;
return async ({ update }) => {
await update();
refreshing = false;
};
}}
class="sm:hidden mb-2"
>
<button
type="submit"
disabled={refreshing}
class="w-full px-3 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{refreshing ? 'Queuing…' : 'Refresh catalogue'}
</button>
</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">
<input type="hidden" name="page" value="1" />
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="flex flex-col gap-1">
<label for="filter-sort" class="text-xs text-zinc-500 uppercase tracking-wide">Sort</label>
<select
id="filter-sort"
name="sort"
value={data.sort}
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 w-full"
>
{#each sorts as s}
<option value={s.value}>{s.label}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1">
<label for="filter-genre" class="text-xs text-zinc-500 uppercase tracking-wide">Genre</label>
<select
id="filter-genre"
name="genre"
value={data.genre}
disabled={isRankView}
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed w-full"
>
{#each genres as g}
<option value={g.value}>{g.label}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1">
<label for="filter-status" class="text-xs text-zinc-500 uppercase tracking-wide">Status</label>
<select
id="filter-status"
name="status"
value={data.status}
disabled={isRankView}
class="bg-zinc-900 border border-zinc-700 text-zinc-200 text-sm rounded px-3 py-2 focus:outline-none focus:border-amber-400 disabled:opacity-40 disabled:cursor-not-allowed w-full"
>
{#each statuses as st}
<option value={st.value}>{st.label}</option>
{/each}
</select>
</div>
</div>
{#if isRankView}
<p class="text-xs text-zinc-500 italic">Genre &amp; status filters apply to Browse only</p>
{/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">
Reset
</a>
<button
type="submit"
onclick={() => (filtersOpen = false)}
class="px-4 py-2 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors"
>
Apply
</button>
</div>
</form>
{/if}
<!-- Content -->
{#if novels.length === 0}
<div class="text-center py-20 text-zinc-500">
<p class="text-lg">{isSearchView ? 'No results found.' : isRankView ? 'No ranking data.' : 'No novels found.'}</p>
<p class="text-sm mt-2">
{#if isSearchView}
Try a different search term.
{:else if isRankView}
{#if data.isAdmin}
Click <span class="text-amber-400">Refresh catalogue</span> above to trigger a full catalogue scrape.
{:else}
Ask an admin to run a catalogue scrape.
{/if}
{:else}
Try different filters or check back later.
{/if}
</p>
</div>
{:else if view === 'grid'}
<!-- ── Grid view ─────────────────────────────────────────────────────── -->
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{#each novels as novel}
{@const isLoading = loadingSlug === novel.slug}
<a
href="/books/{novel.slug}"
onclick={() => handleNovelClick(novel.slug)}
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 border transition-colors relative
{isLoading ? 'border-amber-400/60' : 'border-zinc-700 hover:border-zinc-500'}"
>
<!-- Cover -->
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
{#if novel.cover}
<img
src={novel.cover}
alt={novel.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<svg class="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
{#if novel.rank}
<span class="absolute top-1 left-1 text-xs px-1.5 py-0.5 rounded bg-zinc-900/80 text-amber-400 font-bold">
{novel.rank}
</span>
{/if}
{#if novel.rating}
<span class="absolute top-1 right-1 text-xs px-1.5 py-0.5 rounded bg-zinc-900/80 text-zinc-300">
{novel.rating}
</span>
{/if}
<!-- Loading overlay -->
{#if isLoading}
<div class="absolute inset-0 bg-zinc-900/70 flex items-center justify-center">
<svg class="w-8 h-8 animate-spin text-amber-400" 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>
{/if}
</div>
<!-- Info -->
<div class="p-2 flex flex-col gap-1 flex-1">
<h2 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{novel.title}</h2>
{#if novel.author}
<p class="text-xs text-zinc-500 truncate">{novel.author}</p>
{:else if novel.chapters}
<p class="text-xs text-zinc-500 truncate">{novel.chapters}</p>
{/if}
<!-- Admin: per-novel scrape button -->
{#if data.isAdmin && novel.url}
<div class="mt-auto pt-1">
{#if scrapeResult[novel.slug] === 'queued'}
<span class="text-xs text-emerald-400 font-medium">Queued</span>
{:else if scrapeResult[novel.slug] === 'busy'}
<span class="text-xs text-yellow-400 font-medium">Scraper busy</span>
{:else if scrapeResult[novel.slug] === 'forbidden'}
<span class="text-xs text-red-400 font-medium">Forbidden</span>
{:else if scrapeResult[novel.slug] === 'error'}
<span class="text-xs text-red-400 font-medium">Error</span>
{:else}
<button
onclick={(e) => { e.preventDefault(); scrapeNovel(novel); }}
disabled={scraping[novel.slug]}
class="w-full text-xs px-2 py-1 rounded bg-amber-500/20 text-amber-300 hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30"
>
{scraping[novel.slug] ? 'Scraping…' : 'Scrape'}
</button>
{/if}
</div>
{/if}
</div>
</a>
{/each}
</div>
{:else}
<!-- ── List view ─────────────────────────────────────────────────────── -->
<div class="flex flex-col gap-2">
{#each novels as novel}
{@const isLoading = loadingSlug === novel.slug}
<div
class="flex items-center gap-4 bg-zinc-800 border rounded-lg px-4 py-3 transition-colors
{isLoading ? 'border-amber-400/60' : 'border-zinc-700 hover:border-zinc-500'}"
>
<!-- Rank / index -->
{#if novel.rank}
<span class="text-amber-400 font-bold text-sm w-8 shrink-0 text-right">{novel.rank}</span>
{/if}
<!-- Cover thumbnail -->
<div class="w-10 h-14 shrink-0 rounded overflow-hidden bg-zinc-900 relative">
{#if novel.cover}
<img src={novel.cover} alt={novel.title} class="w-full h-full object-cover" loading="lazy" />
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
{#if isLoading}
<div class="absolute inset-0 bg-zinc-900/70 flex items-center justify-center">
<svg class="w-4 h-4 animate-spin text-amber-400" 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>
{/if}
</div>
<!-- Title + meta -->
<div class="flex-1 min-w-0">
{#if novel.slug}
<a
href="/books/{novel.slug}"
onclick={() => handleNovelClick(novel.slug)}
class="text-sm font-semibold transition-colors line-clamp-1
{isLoading ? 'text-amber-400' : 'text-zinc-100 hover:text-amber-400'}"
>
{novel.title}
</a>
{:else}
<span class="text-sm font-semibold text-zinc-100 line-clamp-1">{novel.title}</span>
{/if}
<div class="flex items-center gap-2 mt-0.5 flex-wrap">
{#if novel.author}
<span class="text-xs text-zinc-400">{novel.author}</span>
{/if}
{#if novel.status}
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-300">{novel.status}</span>
{:else if novel.chapters}
<span class="text-xs text-zinc-500">{novel.chapters}</span>
{/if}
{#if novel.rating}
<span class="text-xs px-1.5 py-0.5 rounded bg-zinc-700 text-zinc-400">{novel.rating}</span>
{/if}
{#if novel.genres?.length}
{#each novel.genres.slice(0, 3) as genre}
<span class="text-xs px-1 py-0.5 rounded bg-zinc-900 text-zinc-500">{genre}</span>
{/each}
{/if}
</div>
</div>
<!-- Admin: per-novel scrape button (list view) -->
{#if data.isAdmin && novel.url}
<div class="shrink-0">
{#if scrapeResult[novel.slug] === 'queued'}
<span class="text-xs text-emerald-400 font-medium">Queued</span>
{:else if scrapeResult[novel.slug] === 'busy'}
<span class="text-xs text-yellow-400 font-medium">Busy</span>
{:else if scrapeResult[novel.slug] === 'error'}
<span class="text-xs text-red-400 font-medium">Error</span>
{:else}
<button
onclick={() => scrapeNovel(novel)}
disabled={scraping[novel.slug]}
class="text-xs px-2.5 py-1 rounded bg-amber-500/20 text-amber-300 hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30 whitespace-nowrap"
>
{scraping[novel.slug] ? 'Scraping…' : 'Scrape'}
</button>
{/if}
</div>
{/if}
<!-- External link -->
{#if novel.source_url || novel.url}
<a
href={novel.source_url ?? novel.url}
target="_blank"
rel="noopener noreferrer"
class="shrink-0 text-zinc-500 hover:text-zinc-300 transition-colors"
title="Open on novelfire.net"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Infinite scroll sentinel (browse mode only — not rank, not search) -->
{#if !isRankView && !isSearchView}
{#if hasNext}
<!-- Invisible div watched by IntersectionObserver -->
<div bind:this={sentinel} class="h-px mt-8"></div>
{/if}
<!-- Loading spinner while fetching next page -->
{#if loadingMore}
<div class="flex justify-center py-8">
<svg class="w-6 h-6 animate-spin text-amber-400" 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>
{:else if !hasNext && novels.length > 0}
<p class="text-center text-zinc-600 text-xs mt-8 pb-4">All novels loaded</p>
{/if}
{/if}
<!-- Scroll-to-top button -->
{#if showScrollTop}
<button
onclick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
class="fixed bottom-6 right-6 z-50 p-3 rounded-full bg-zinc-800 border border-zinc-600 text-zinc-300 shadow-lg hover:bg-zinc-700 hover:text-zinc-100 transition-colors"
title="Back to top"
aria-label="Scroll to top"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
{/if}

View File

@@ -0,0 +1,34 @@
<svelte:head>
<title>Disclaimer — libnovel</title>
</svelte:head>
<div class="max-w-2xl mx-auto py-10 px-4">
<h1 class="text-2xl font-bold text-zinc-100 mb-6">Disclaimer</h1>
<div class="space-y-5 text-sm text-zinc-400 leading-relaxed">
<p>
libnovel is a personal reading tool that indexes and caches publicly accessible novel content
from third-party sources, primarily <a href="https://novelfire.net" target="_blank" rel="noopener noreferrer" class="text-amber-400 hover:text-amber-300 transition-colors">novelfire.net</a>.
It is not affiliated with, endorsed by, or in any way officially connected to those sources.
</p>
<p>
All novel titles, cover images, chapter text, and related materials are the property of their
respective authors and publishers. libnovel does not claim ownership of any of this content.
The content is reproduced solely for personal, non-commercial reading convenience.
</p>
<p>
If you are a rights holder and believe your work is being used without authorisation, please
refer to our <a href="/dmca" class="text-amber-400 hover:text-amber-300 transition-colors">DMCA policy</a>
for instructions on how to request removal.
</p>
<p>
libnovel makes no warranties regarding the accuracy, completeness, or timeliness of any
content displayed. Use of this site is at your own risk.
</p>
<p class="text-zinc-600 text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
</div>
</div>

View File

@@ -0,0 +1,45 @@
<svelte:head>
<title>DMCA — libnovel</title>
</svelte:head>
<div class="max-w-2xl mx-auto py-10 px-4">
<h1 class="text-2xl font-bold text-zinc-100 mb-6">DMCA Takedown Policy</h1>
<div class="prose-zinc space-y-5 text-sm text-zinc-400 leading-relaxed">
<p>
libnovel respects the intellectual property rights of authors, publishers, and other content
creators. If you believe that content available through this site infringes your copyright,
please send a written takedown notice to the contact address below.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Your notice must include</h2>
<ol class="list-decimal list-inside space-y-2 pl-1">
<li>Your full legal name and contact information (email address).</li>
<li>A description of the copyrighted work you claim has been infringed.</li>
<li>The specific URL(s) on this site where the allegedly infringing content appears.</li>
<li>
A statement that you have a good-faith belief that the use is not authorised by the copyright
owner, its agent, or the law.
</li>
<li>
A statement, made under penalty of perjury, that the information in your notice is accurate
and that you are the copyright owner or authorised to act on their behalf.
</li>
<li>Your electronic or physical signature.</li>
</ol>
<h2 class="text-base font-semibold text-zinc-200 mt-6">How to submit</h2>
<p>
Send your notice by email to <span class="text-zinc-300 font-medium">dmca@libnovel.local</span>.
We will review valid notices and remove or disable access to the identified content promptly.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Counter-notices</h2>
<p>
If you believe content was removed in error, you may submit a counter-notice to the same
address with the information required under 17 U.S.C. § 512(g)(3).
</p>
<p class="text-zinc-600 text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
</div>
</div>

View File

@@ -0,0 +1,6 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = () => {
return json({ status: 'ok' });
};

View File

@@ -0,0 +1,142 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { loginUser, createUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase';
import { createAuthToken } from '../../hooks.server';
import { log } from '$lib/server/logger';
import { randomBytes } from 'node:crypto';
const AUTH_COOKIE = 'libnovel_auth';
const ONE_YEAR = 60 * 60 * 24 * 365;
export const load: PageServerLoad = async ({ locals }) => {
// Already logged in — send to home
if (locals.user) {
redirect(302, '/');
}
return {};
};
export const actions: Actions = {
login: async ({ request, cookies, locals }) => {
const data = await request.formData();
const username = (data.get('username') as string | null)?.trim() ?? '';
const password = (data.get('password') as string | null) ?? '';
if (!username || !password) {
return fail(400, { action: 'login', error: 'Username and password are required.' });
}
let user;
try {
user = await loginUser(username, password);
} catch (err) {
log.error('auth', 'login unexpected error', { username, err: String(err) });
return fail(500, { action: 'login', error: 'An error occurred. Please try again.' });
}
if (!user) {
return fail(401, { action: 'login', error: 'Invalid username or password.' });
}
// Merge any anonymous session progress into the user's account so that
// chapters read before logging in are preserved and portable across devices.
mergeSessionProgress(locals.sessionId, user.id).catch((err) =>
log.warn('auth', 'login: mergeSessionProgress failed (non-fatal)', { err: String(err) })
);
// Create a unique auth session ID for this login
const authSessionId = randomBytes(16).toString('hex');
// Record the session in PocketBase (best-effort, non-fatal)
const userAgent = request.headers.get('user-agent') ?? '';
const ip =
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
request.headers.get('x-real-ip') ??
'';
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
log.warn('auth', 'login: createUserSession failed (non-fatal)', { err: String(err) })
);
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
cookies.set(AUTH_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: ONE_YEAR
});
redirect(302, '/');
},
register: async ({ request, cookies, locals }) => {
const data = await request.formData();
const username = (data.get('username') as string | null)?.trim() ?? '';
const password = (data.get('password') as string | null) ?? '';
const confirm = (data.get('confirm') as string | null) ?? '';
if (!username || !password) {
return fail(400, { action: 'register', error: 'Username and password are required.' });
}
if (username.length < 3 || username.length > 32) {
return fail(400, {
action: 'register',
error: 'Username must be between 3 and 32 characters.'
});
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
return fail(400, {
action: 'register',
error: 'Username may only contain letters, numbers, underscores and hyphens.'
});
}
if (password.length < 8) {
return fail(400, {
action: 'register',
error: 'Password must be at least 8 characters.'
});
}
if (password !== confirm) {
return fail(400, { action: 'register', error: 'Passwords do not match.' });
}
let user;
try {
user = await createUser(username, password);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Registration failed.';
if (msg.includes('Username already taken')) {
return fail(409, { action: 'register', error: 'That username is already taken.' });
}
log.error('auth', 'register unexpected error', { username, err: String(err) });
return fail(500, { action: 'register', error: 'An error occurred. Please try again.' });
}
// Merge any anonymous session progress into the newly created account.
mergeSessionProgress(locals.sessionId, user.id).catch((err) =>
log.warn('auth', 'register: mergeSessionProgress failed (non-fatal)', { err: String(err) })
);
// Create a unique auth session ID for this registration
const authSessionId = randomBytes(16).toString('hex');
// Record the session in PocketBase (best-effort, non-fatal)
const userAgent = request.headers.get('user-agent') ?? '';
const ip =
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
request.headers.get('x-real-ip') ??
'';
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
log.warn('auth', 'register: createUserSession failed (non-fatal)', { err: String(err) })
);
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
cookies.set(AUTH_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: ONE_YEAR
});
redirect(302, '/');
}
};

View File

@@ -0,0 +1,136 @@
<script lang="ts">
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
let mode: 'login' | 'register' = $state('login');
</script>
<svelte:head>
<title>Sign in — libnovel</title>
</svelte:head>
<div class="flex items-center justify-center min-h-[60vh]">
<div class="w-full max-w-sm">
<!-- Tab switcher -->
<div class="flex mb-6 border-b border-zinc-700">
<button
type="button"
onclick={() => (mode = 'login')}
class="flex-1 pb-3 text-sm font-medium transition-colors
{mode === 'login'
? 'text-amber-400 border-b-2 border-amber-400 -mb-px'
: 'text-zinc-400 hover:text-zinc-100'}"
>
Sign in
</button>
<button
type="button"
onclick={() => (mode = 'register')}
class="flex-1 pb-3 text-sm font-medium transition-colors
{mode === 'register'
? 'text-amber-400 border-b-2 border-amber-400 -mb-px'
: 'text-zinc-400 hover:text-zinc-100'}"
>
Create account
</button>
</div>
{#if form?.error && (form?.action === mode || !form?.action)}
<div class="mb-4 rounded bg-red-900/40 border border-red-700 px-4 py-3 text-sm text-red-300">
{form.error}
</div>
{/if}
{#if mode === 'login'}
<form method="POST" action="?/login" class="flex flex-col gap-4">
<div>
<label for="login-username" class="block text-xs text-zinc-400 mb-1">Username</label>
<input
id="login-username"
name="username"
type="text"
autocomplete="username"
required
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="your_username"
/>
</div>
<div>
<label for="login-password" class="block text-xs text-zinc-400 mb-1">Password</label>
<input
id="login-password"
name="password"
type="password"
autocomplete="current-password"
required
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="••••••••"
/>
</div>
<button
type="submit"
class="w-full py-2 rounded bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
>
Sign in
</button>
</form>
{:else}
<form method="POST" action="?/register" class="flex flex-col gap-4">
<div>
<label for="reg-username" class="block text-xs text-zinc-400 mb-1">Username</label>
<input
id="reg-username"
name="username"
type="text"
autocomplete="username"
required
minlength="3"
maxlength="32"
pattern="[a-zA-Z0-9_\-]+"
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="your_username"
/>
<p class="mt-1 text-xs text-zinc-500">332 characters: letters, numbers, _ or -</p>
</div>
<div>
<label for="reg-password" class="block text-xs text-zinc-400 mb-1">Password</label>
<input
id="reg-password"
name="password"
type="password"
autocomplete="new-password"
required
minlength="8"
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="••••••••"
/>
<p class="mt-1 text-xs text-zinc-500">At least 8 characters</p>
</div>
<div>
<label for="reg-confirm" class="block text-xs text-zinc-400 mb-1">Confirm password</label>
<input
id="reg-confirm"
name="confirm"
type="password"
autocomplete="new-password"
required
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
placeholder="••••••••"
/>
</div>
<button
type="submit"
class="w-full py-2 rounded bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
>
Create account
</button>
</form>
{/if}
</div>
</div>

View File

@@ -0,0 +1,11 @@
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
const AUTH_COOKIE = 'libnovel_auth';
export const actions: Actions = {
default: async ({ cookies }) => {
cookies.delete(AUTH_COOKIE, { path: '/' });
redirect(302, '/login');
}
};

View File

@@ -0,0 +1,55 @@
<svelte:head>
<title>Privacy Policy — libnovel</title>
</svelte:head>
<div class="max-w-2xl mx-auto py-10 px-4">
<h1 class="text-2xl font-bold text-zinc-100 mb-6">Privacy Policy</h1>
<div class="space-y-5 text-sm text-zinc-400 leading-relaxed">
<p>
This policy describes what limited data libnovel collects and how it is used.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Data we collect</h2>
<ul class="list-disc list-inside space-y-2 pl-1">
<li>
<span class="text-zinc-300">Session cookies</span> — a short-lived cookie is set when you
visit the site to track reading progress across pages. No account is required.
</li>
<li>
<span class="text-zinc-300">Account data (optional)</span> — if you create an account,
we store your username and a hashed password. No email address is required.
</li>
<li>
<span class="text-zinc-300">Reading progress</span> — the last chapter you read for each
book is stored server-side, tied to your session or account, so you can resume reading.
</li>
<li>
<span class="text-zinc-300">Saved books</span> — books you explicitly bookmark are stored
server-side tied to your session or account.
</li>
</ul>
<h2 class="text-base font-semibold text-zinc-200 mt-6">What we do not collect</h2>
<ul class="list-disc list-inside space-y-2 pl-1">
<li>No email addresses (unless you choose to provide one).</li>
<li>No tracking pixels, analytics scripts, or third-party ad networks.</li>
<li>No selling or sharing of data with third parties.</li>
</ul>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Third-party content</h2>
<p>
Cover images and chapter content are fetched from third-party sources (e.g.
<a href="https://novelfire.net" target="_blank" rel="noopener noreferrer" class="text-amber-400 hover:text-amber-300 transition-colors">novelfire.net</a>).
Your browser may make requests directly to those domains when loading images.
</p>
<h2 class="text-base font-semibold text-zinc-200 mt-6">Data deletion</h2>
<p>
You can delete your reading progress and saved books from your profile page at any time.
To request full account deletion, contact us via the <a href="/dmca" class="text-amber-400 hover:text-amber-300 transition-colors">contact address listed in our DMCA policy</a>.
</p>
<p class="text-zinc-600 text-xs mt-8">Last updated: {new Date().getFullYear()}</p>
</div>
</div>

View File

@@ -0,0 +1,79 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { changePassword, listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
import { presignAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) {
redirect(302, '/login');
}
let sessions: Awaited<ReturnType<typeof listUserSessions>> = [];
try {
sessions = await listUserSessions(locals.user.id);
} catch (e) {
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
}
// Fetch avatar presigned URL if user has one
let avatarUrl: string | null = null;
try {
const record = await getUserByUsername(locals.user.username);
if (record?.avatar_url) {
avatarUrl = await presignAvatarUrl(locals.user.id);
}
} catch (e) {
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
}
return {
user: locals.user,
avatarUrl,
sessions: sessions.map((s) => ({
id: s.id,
user_agent: s.user_agent,
ip: s.ip,
created_at: s.created_at,
last_seen: s.last_seen,
is_current: s.session_id === locals.user!.authSessionId
}))
};
};
export const actions: Actions = {
changePassword: async ({ request, locals }) => {
if (!locals.user) {
return fail(401, { error: 'Not logged in.' });
}
const data = await request.formData();
const current = (data.get('current') as string | null) ?? '';
const next = (data.get('next') as string | null) ?? '';
const confirm = (data.get('confirm') as string | null) ?? '';
if (!current || !next || !confirm) {
return fail(400, { error: 'All fields are required.' });
}
if (next.length < 8) {
return fail(400, { error: 'New password must be at least 8 characters.' });
}
if (next !== confirm) {
return fail(400, { error: 'New passwords do not match.' });
}
let ok: boolean;
try {
ok = await changePassword(locals.user.id, current, next);
} catch (e) {
log.error('profile', 'changePassword failed', { err: String(e) });
return fail(500, { error: 'An error occurred. Please try again.' });
}
if (!ok) {
return fail(401, { error: 'Current password is incorrect.' });
}
return { success: true };
}
};

View File

@@ -0,0 +1,474 @@
<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';
let { data, form }: { data: PageData; form: ActionData } = $props();
// ── Avatar ───────────────────────────────────────────────────────────────────
let avatarUrl = $state<string | null>(untrack(() => data.avatarUrl ?? null));
let avatarUploading = $state(false);
let avatarError = $state('');
let fileInput: HTMLInputElement | null = null;
// Crop modal state
let cropFile = $state<File | null>(null);
function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// Reset input so the same file can be re-selected after cancel
if (fileInput) fileInput.value = '';
cropFile = file;
}
async function handleCropConfirm(blob: Blob, mimeType: string) {
cropFile = null;
avatarUploading = true;
avatarError = '';
try {
// Step 1: get presigned PUT URL
const presignRes = 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})`;
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 };
avatarUrl = result.avatar_url;
} catch {
avatarError = 'Network error during upload';
} finally {
avatarUploading = false;
}
}
function handleCropCancel() {
cropFile = null;
}
// ── Settings ────────────────────────────────────────────────────────────────
let voices = $state<string[]>([]);
let voicesLoaded = $state(false);
// Load voices on mount
$effect(() => {
fetch('/api/voices')
.then((r) => r.json())
.then((d: { voices: string[] }) => {
voices = d.voices ?? [];
voicesLoaded = true;
})
.catch(() => {
voicesLoaded = true;
});
});
// Mirror from audioStore so sliders feel live
let voice = $state(audioStore.voice);
let speed = $state(audioStore.speed);
let autoNext = $state(audioStore.autoNext);
// Keep in sync when layout changes them externally
$effect(() => {
voice = audioStore.voice;
speed = audioStore.speed;
autoNext = audioStore.autoNext;
});
let settingsSaving = $state(false);
let settingsSaved = $state(false);
async function saveSettings() {
settingsSaving = true;
settingsSaved = false;
try {
await fetch('/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoNext, voice, speed })
});
// Sync to audioStore so the player picks up changes immediately
audioStore.autoNext = autoNext;
audioStore.voice = voice;
audioStore.speed = speed;
await invalidateAll();
settingsSaved = true;
setTimeout(() => (settingsSaved = false), 2500);
} finally {
settingsSaving = false;
}
}
// ── Password change ─────────────────────────────────────────────────────────
let pwSubmitting = $state(false);
let pwSuccess = $state(false);
$effect(() => {
if (form?.success) {
pwSuccess = true;
setTimeout(() => (pwSuccess = false), 3000);
}
});
// ── Sessions ────────────────────────────────────────────────────────────────
type Session = {
id: string;
user_agent: string;
ip: string;
created_at: string;
last_seen: string;
is_current: boolean;
};
let sessions = $state<Session[]>(untrack(() => data.sessions ?? []));
let revokingId = $state<string | null>(null);
let revokeError = $state('');
async function revokeSession(session: Session) {
revokingId = session.id;
revokeError = '';
try {
const res = await fetch(`/api/sessions/${session.id}`, { method: 'DELETE' });
if (!res.ok) {
revokeError = 'Failed to end session. Please try again.';
return;
}
if (session.is_current) {
// Ended our own session — submit the logout form to clear the cookie
const logoutForm = document.getElementById('logout-form') as HTMLFormElement | null;
if (logoutForm) {
logoutForm.submit();
}
return;
}
// Remove from local list
sessions = sessions.filter((s) => s.id !== session.id);
} catch {
revokeError = 'Network error. Please try again.';
} finally {
revokingId = null;
}
}
function formatDate(iso: string): string {
if (!iso) return '—';
try {
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(iso));
} catch {
return iso;
}
}
function parseUA(ua: string): string {
if (!ua) return 'Unknown browser';
// Very lightweight UA display — just show the most meaningful part
if (/Mobile/i.test(ua)) {
const match = ua.match(/\(([^)]+)\)/);
return match ? `Mobile — ${match[1].split(';')[0].trim()}` : 'Mobile device';
}
if (/Chrome\/(\d+)/i.test(ua)) return `Chrome ${ua.match(/Chrome\/(\d+)/i)![1]}`;
if (/Firefox\/(\d+)/i.test(ua)) return `Firefox ${ua.match(/Firefox\/(\d+)/i)![1]}`;
if (/Safari\/(\d+)/i.test(ua) && !/Chrome/i.test(ua)) return 'Safari';
if (/Edg\/(\d+)/i.test(ua)) return `Edge ${ua.match(/Edg\/(\d+)/i)![1]}`;
return ua.slice(0, 48) + (ua.length > 48 ? '…' : '');
}
</script>
<svelte:head>
<title>Profile — libnovel</title>
</svelte:head>
{#if cropFile && browser}
{#await import('$lib/components/AvatarCropModal.svelte') then { default: AvatarCropModal }}
<AvatarCropModal
file={cropFile}
onconfirm={handleCropConfirm}
oncancel={handleCropCancel}
/>
{/await}
{/if}
<!-- Hidden logout form used when user ends their own session -->
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
<div class="max-w-xl mx-auto space-y-10">
<div class="flex items-center gap-5">
<!-- Avatar -->
<div class="relative shrink-0">
<button
onclick={() => fileInput?.click()}
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-zinc-600 hover:ring-amber-400 transition-all focus:outline-none focus:ring-amber-400"
title="Change profile picture"
disabled={avatarUploading}
>
{#if avatarUrl}
<img src={avatarUrl} alt="Profile" class="w-full h-full object-cover" />
{:else}
<div class="w-full h-full bg-zinc-700 flex items-center justify-center">
<svg class="w-10 h-10 text-zinc-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
</div>
{/if}
<!-- Hover overlay -->
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{#if avatarUploading}
<svg class="w-5 h-5 text-white 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-8v8H4z"></path>
</svg>
{:else}
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
{/if}
</div>
</button>
<input
bind:this={fileInput}
type="file"
accept="image/jpeg,image/png,image/webp"
class="hidden"
onchange={handleAvatarChange}
/>
</div>
<div>
<h1 class="text-2xl font-bold text-zinc-100">{data.user.username}</h1>
<p class="text-zinc-400 text-sm mt-0.5 capitalize">{data.user.role}</p>
{#if avatarError}
<p class="text-red-400 text-xs mt-1">{avatarError}</p>
{:else}
<p class="text-zinc-500 text-xs mt-1">Click avatar to change photo</p>
{/if}
</div>
</div>
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
<section class="bg-zinc-800 rounded-xl border border-zinc-700 p-6 space-y-5">
<h2 class="text-lg font-semibold text-zinc-100">Reading settings</h2>
<!-- Voice -->
<div class="space-y-1.5">
<label class="block text-sm font-medium text-zinc-300" for="voice-select">TTS voice</label>
{#if !voicesLoaded}
<div class="h-9 bg-zinc-700 rounded animate-pulse"></div>
{:else if voices.length === 0}
<select id="voice-select" disabled class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-400 text-sm cursor-not-allowed">
<option>No voices available</option>
</select>
{:else}
<select
id="voice-select"
bind:value={voice}
class="w-full bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400"
>
{#each voices as v}
<option value={v}>{v}</option>
{/each}
</select>
{/if}
</div>
<!-- Speed -->
<div class="space-y-1.5">
<label class="block text-sm font-medium text-zinc-300" for="speed-range">
Playback speed — <span class="text-amber-400 font-mono">{speed.toFixed(1)}x</span>
</label>
<input
id="speed-range"
type="range"
min="0.5"
max="3.0"
step="0.1"
bind:value={speed}
class="w-full accent-amber-400"
/>
<div class="flex justify-between text-xs text-zinc-500">
<span>0.5x</span>
<span>3.0x</span>
</div>
</div>
<!-- Auto-next -->
<label class="flex items-center gap-3 cursor-pointer select-none">
<input
type="checkbox"
bind:checked={autoNext}
class="w-4 h-4 rounded accent-amber-400"
/>
<span class="text-sm text-zinc-300">Auto-advance to next chapter</span>
</label>
<div class="flex items-center gap-3 pt-1">
<button
onclick={saveSettings}
disabled={settingsSaving}
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-60"
>
{settingsSaving ? 'Saving…' : 'Save settings'}
</button>
{#if settingsSaved}
<span class="text-sm text-green-400">Saved!</span>
{/if}
</div>
</section>
<!-- ── Active sessions ──────────────────────────────────────────────────── -->
<section class="bg-zinc-800 rounded-xl border border-zinc-700 p-6 space-y-4">
<h2 class="text-lg font-semibold text-zinc-100">Active sessions</h2>
<p class="text-sm text-zinc-400">These are all devices currently signed into your account. End any session you don't recognise.</p>
{#if revokeError}
<div class="rounded-lg bg-red-900/40 border border-red-700 px-4 py-2.5 text-sm text-red-300">
{revokeError}
</div>
{/if}
{#if sessions.length === 0}
<p class="text-sm text-zinc-500 italic">No session records found. Sessions are tracked from the next login.</p>
{:else}
<ul class="space-y-2">
{#each sessions as session (session.id)}
<li class="flex items-start justify-between gap-3 rounded-lg px-4 py-3 {session.is_current ? 'bg-amber-400/10 border border-amber-400/30' : 'bg-zinc-700/50 border border-zinc-600/50'}">
<div class="min-w-0 space-y-0.5">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-medium text-zinc-100 truncate">{parseUA(session.user_agent)}</span>
{#if session.is_current}
<span class="shrink-0 text-xs font-semibold px-1.5 py-0.5 rounded bg-amber-400/20 text-amber-300 border border-amber-400/40">This session</span>
{/if}
</div>
{#if session.ip}
<p class="text-xs text-zinc-400 font-mono">{session.ip}</p>
{/if}
<p class="text-xs text-zinc-500">
Signed in {formatDate(session.created_at)}
{#if session.last_seen && session.last_seen !== session.created_at}
· Last seen {formatDate(session.last_seen)}
{/if}
</p>
</div>
<button
onclick={() => revokeSession(session)}
disabled={revokingId === session.id}
class="shrink-0 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
{session.is_current
? 'bg-red-900/40 text-red-300 border border-red-700/60 hover:bg-red-900/70'
: 'bg-zinc-600/60 text-zinc-300 border border-zinc-500/50 hover:bg-zinc-600'}"
>
{revokingId === session.id ? '…' : session.is_current ? 'Sign out' : 'End'}
</button>
</li>
{/each}
</ul>
{/if}
</section>
<!-- ── Change password ──────────────────────────────────────────────────── -->
<section class="bg-zinc-800 rounded-xl border border-zinc-700 p-6 space-y-4">
<h2 class="text-lg font-semibold text-zinc-100">Change password</h2>
{#if form?.error}
<div class="rounded-lg bg-red-900/40 border border-red-700 px-4 py-2.5 text-sm text-red-300">
{form.error}
</div>
{/if}
{#if pwSuccess}
<div class="rounded-lg bg-green-900/40 border border-green-700 px-4 py-2.5 text-sm text-green-300">
Password changed successfully.
</div>
{/if}
<form
method="POST"
action="?/changePassword"
use:enhance={() => {
pwSubmitting = true;
return async ({ update }) => {
pwSubmitting = false;
await update();
};
}}
class="space-y-4"
>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-zinc-300" for="current">Current password</label>
<input
id="current"
name="current"
type="password"
autocomplete="current-password"
required
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>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-zinc-300" for="next">New password</label>
<input
id="next"
name="next"
type="password"
autocomplete="new-password"
required
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>
<div class="space-y-1.5">
<label class="block text-sm font-medium text-zinc-300" for="confirm">Confirm new password</label>
<input
id="confirm"
name="confirm"
type="password"
autocomplete="new-password"
required
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>
<button
type="submit"
disabled={pwSubmitting}
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-60"
>
{pwSubmitting ? 'Updating…' : 'Update password'}
</button>
</form>
</section>
</div>

View File

@@ -0,0 +1,59 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import {
getPublicProfile,
getSubscription,
getUserPublicLibrary,
getUserCurrentlyReading
} from '$lib/server/pocketbase';
import { presignAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
export const load: PageServerLoad = async ({ params, locals }) => {
const { username } = params;
const profile = await getPublicProfile(username).catch(() => null);
if (!profile) error(404, `User "${username}" not found`);
// Resolve avatar
let avatarUrl: string | null = null;
if (profile.avatar_url) {
avatarUrl = await presignAvatarUrl(profile.id).catch(() => null);
}
// Subscription state for the logged-in visitor
let isSubscribed = false;
const isSelf = locals.user?.id === profile.id;
if (locals.user && !isSelf) {
const sub = await getSubscription(locals.user.id, profile.id).catch(() => null);
isSubscribed = !!sub;
}
// Load public library + currently reading in parallel
const [library, currentlyReading] = await Promise.all([
getUserPublicLibrary(profile.id).catch((e) => {
log.error('users/profile', 'getUserPublicLibrary failed', { username, err: String(e) });
return [] as Awaited<ReturnType<typeof getUserPublicLibrary>>;
}),
getUserCurrentlyReading(profile.id).catch((e) => {
log.error('users/profile', 'getUserCurrentlyReading failed', { username, err: String(e) });
return [] as Awaited<ReturnType<typeof getUserCurrentlyReading>>;
})
]);
return {
profile: {
id: profile.id,
username: profile.username,
created: profile.created,
followerCount: profile.followerCount,
followingCount: profile.followingCount
},
avatarUrl,
isSubscribed,
isSelf,
isLoggedIn: !!locals.user,
library,
currentlyReading
};
};

View File

@@ -0,0 +1,225 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
// ── Subscribe / unsubscribe ──────────────────────────────────────────────────
let subscribed = $state(untrack(() => data.isSubscribed));
let followerCount = $state(untrack(() => data.profile.followerCount));
let subLoading = $state(false);
async function toggleSubscribe() {
if (subLoading) return;
subLoading = true;
try {
const method = subscribed ? 'DELETE' : 'POST';
const res = await fetch(`/api/users/${data.profile.username}/subscribe`, { method });
if (res.ok) {
subscribed = !subscribed;
followerCount += subscribed ? 1 : -1;
}
} finally {
subLoading = false;
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function initials(username: string): string {
return username.slice(0, 2).toUpperCase();
}
function joinDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long' });
} catch {
return '';
}
}
function parseGenres(genres: string[] | string | null | undefined): string[] {
if (!genres) return [];
if (Array.isArray(genres)) return genres;
try {
const parsed = JSON.parse(genres);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
</script>
<svelte:head>
<title>{data.profile.username} — libnovel</title>
</svelte:head>
<!-- ── Header ────────────────────────────────────────────────────────────── -->
<div class="flex items-start gap-5 mb-8">
<!-- Avatar -->
<div class="flex-shrink-0">
{#if data.avatarUrl}
<img
src={data.avatarUrl}
alt={data.profile.username}
class="w-20 h-20 rounded-full object-cover ring-2 ring-zinc-700"
/>
{:else}
<div class="w-20 h-20 rounded-full bg-zinc-700 flex items-center justify-center text-2xl font-bold text-zinc-300 ring-2 ring-zinc-600">
{initials(data.profile.username)}
</div>
{/if}
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<h1 class="text-xl font-bold text-zinc-100 mb-0.5">{data.profile.username}</h1>
<p class="text-xs text-zinc-500 mb-3">Joined {joinDate(data.profile.created)}</p>
<!-- Stats row -->
<div class="flex gap-5 text-sm mb-4">
<span>
<span class="font-semibold text-zinc-100">{followerCount}</span>
<span class="text-zinc-500 ml-1">followers</span>
</span>
<span>
<span class="font-semibold text-zinc-100">{data.profile.followingCount}</span>
<span class="text-zinc-500 ml-1">following</span>
</span>
</div>
<!-- Subscribe button — only shown to logged-in visitors viewing someone else's profile -->
{#if data.isLoggedIn && !data.isSelf}
<button
onclick={toggleSubscribe}
disabled={subLoading}
class="px-4 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50
{subscribed
? 'bg-zinc-700 text-zinc-300 hover:bg-zinc-600 border border-zinc-600'
: 'bg-amber-400 text-zinc-900 hover:bg-amber-300'}"
>
{#if subLoading}
{:else if subscribed}
Following
{:else}
Follow
{/if}
</button>
{:else if !data.isLoggedIn}
<a
href="/login"
class="inline-block px-4 py-1.5 rounded-lg text-sm font-medium bg-amber-400 text-zinc-900 hover:bg-amber-300 transition-colors"
>
Follow
</a>
{/if}
</div>
</div>
<!-- ── Currently Reading ─────────────────────────────────────────────────── -->
{#if data.currentlyReading.length > 0}
<section class="mb-10">
<h2 class="text-base font-semibold text-zinc-200 mb-3">Currently Reading</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.currentlyReading as { book, chapter }}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
<span class="absolute bottom-1.5 right-1.5 text-xs bg-amber-400 text-zinc-900 font-bold px-1.5 py-0.5 rounded">
ch.{chapter}
</span>
</div>
<div class="p-2">
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title}</h3>
{#if book.author}
<p class="text-xs text-zinc-500 truncate mt-0.5">{book.author}</p>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- ── Library ───────────────────────────────────────────────────────────── -->
{#if data.library.length > 0}
<section class="mb-10">
<h2 class="text-base font-semibold text-zinc-200 mb-3">
Library
<span class="text-zinc-500 font-normal text-sm ml-1">({data.library.length})</span>
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{#each data.library as { book, chapter, saved }}
{@const genres = parseGenres(book.genres)}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-zinc-800 hover:bg-zinc-700 transition-colors border border-zinc-700 hover:border-zinc-500"
>
<div class="aspect-[2/3] bg-zinc-900 overflow-hidden relative">
{#if book.cover}
<img
src={book.cover}
alt={book.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
{/if}
{#if chapter}
<span class="absolute bottom-1.5 right-1.5 text-xs bg-zinc-900/80 text-zinc-300 font-medium px-1.5 py-0.5 rounded">
ch.{chapter}
</span>
{/if}
{#if saved && !chapter}
<span class="absolute top-1.5 right-1.5">
<svg class="w-3.5 h-3.5 text-amber-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M5 3a2 2 0 00-2 2v16l9-4 9 4V5a2 2 0 00-2-2H5z"/>
</svg>
</span>
{/if}
</div>
<div class="p-2">
<h3 class="text-xs font-semibold text-zinc-100 line-clamp-2 leading-snug">{book.title}</h3>
{#if book.author}
<p class="text-xs text-zinc-500 truncate mt-0.5">{book.author}</p>
{/if}
{#if genres.length > 0}
<p class="text-xs text-zinc-600 truncate mt-0.5">{genres[0]}</p>
{/if}
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- ── Empty state ───────────────────────────────────────────────────────── -->
{#if data.library.length === 0 && data.currentlyReading.length === 0}
<div class="py-16 text-center text-zinc-500">
<svg class="w-10 h-10 mx-auto mb-3 text-zinc-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<p class="text-sm">No books in library yet.</p>
</div>
{/if}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
ui-v2/static/favicon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 B

BIN
ui-v2/static/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 B

BIN
ui-v2/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 B

BIN
ui-v2/static/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
ui-v2/static/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

3
ui-v2/static/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

10
ui-v2/svelte.config.js Normal file
View File

@@ -0,0 +1,10 @@
import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
}
};
export default config;

20
ui-v2/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

18
ui-v2/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
ssr: {
// Force these packages to be bundled into the server output rather than
// treated as external requires. The production Docker image has no
// node_modules, so anything used in server-side code must be inlined.
noExternal: ['marked'],
// cropperjs is DOM-only (used inside $effect); exclude from SSR bundle.
external: ['cropperjs']
},
optimizeDeps: {
include: ['cropperjs']
}
});