feat: profile page, admin pages, infinite scroll on browse
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / Scraper / Build (pull_request) Successful in 16s
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Test (pull_request) Successful in 16s
CI / Scraper / Lint (pull_request) Successful in 19s
CI / Scraper / Build (pull_request) Successful in 16s
- Add /profile page with reading settings (voice, speed, auto-next) and password change form - Add /admin/scrape page showing scraping task history with live status polling and trigger controls - Add /admin/audio page showing audio cache entries with client-side search filter - Add changePassword(), listAudioCache(), listScrapingTasks() to pocketbase.ts - Add /api/admin/scrape and /api/browse-page server-side proxy routes - Replace browse page pagination with IntersectionObserver infinite scroll - Update nav: username becomes a /profile link; admin users see Scrape and Audio cache links
This commit is contained in:
@@ -479,6 +479,44 @@ export async function createUser(username: string, password: string, role = 'use
|
|||||||
return res.json() as Promise<User>;
|
return res.json() as Promise<User>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change a user's password. Verifies the current password first.
|
||||||
|
* Returns true on success, false if currentPassword is wrong.
|
||||||
|
* Throws on unexpected errors.
|
||||||
|
*/
|
||||||
|
export async function changePassword(
|
||||||
|
userId: string,
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
// Fetch the user record directly by id to verify current password
|
||||||
|
const token = await getToken();
|
||||||
|
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text().catch(() => '');
|
||||||
|
log.error('pocketbase', 'changePassword: fetch user failed', { userId, status: res.status, body });
|
||||||
|
throw new Error(`Failed to fetch user: ${res.status}`);
|
||||||
|
}
|
||||||
|
const user = (await res.json()) as User;
|
||||||
|
if (!verifyPassword(currentPassword, user.password_hash)) {
|
||||||
|
log.warn('pocketbase', 'changePassword: wrong current password', { userId });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const newHash = hashPassword(newPassword);
|
||||||
|
const patch = await pbPatch(`/api/collections/app_users/records/${userId}`, {
|
||||||
|
password_hash: newHash
|
||||||
|
});
|
||||||
|
if (!patch.ok) {
|
||||||
|
const body = await patch.text().catch(() => '');
|
||||||
|
log.error('pocketbase', 'changePassword: PATCH failed', { userId, status: patch.status, body });
|
||||||
|
throw new Error(`Failed to update password: ${patch.status}`);
|
||||||
|
}
|
||||||
|
log.info('pocketbase', 'changePassword: success', { userId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify username + password. Returns the user on success, null on failure.
|
* Verify username + password. Returns the user on success, null on failure.
|
||||||
*/
|
*/
|
||||||
@@ -586,6 +624,39 @@ export async function setAudioTime(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Audio cache ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AudioCacheEntry {
|
||||||
|
id: string;
|
||||||
|
cache_key: string;
|
||||||
|
filename: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAudioCache(): Promise<AudioCacheEntry[]> {
|
||||||
|
return listAll<AudioCacheEntry>('audio_cache', '', '-updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scraping tasks ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ScrapingTask {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
target_url: string;
|
||||||
|
status: string;
|
||||||
|
books_found: number;
|
||||||
|
chapters_scraped: number;
|
||||||
|
chapters_skipped: number;
|
||||||
|
errors: number;
|
||||||
|
started: string;
|
||||||
|
finished: string;
|
||||||
|
error_message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listScrapingTasks(): Promise<ScrapingTask[]> {
|
||||||
|
return listAll<ScrapingTask>('scraping_tasks', '', '-started');
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAudioTime(
|
export async function getAudioTime(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
slug: string,
|
slug: string,
|
||||||
|
|||||||
@@ -222,7 +222,26 @@
|
|||||||
Discover
|
Discover
|
||||||
</a>
|
</a>
|
||||||
<div class="ml-auto flex items-center gap-4">
|
<div class="ml-auto flex items-center gap-4">
|
||||||
<span class="text-zinc-400 text-sm hidden sm:block">{data.user.username}</span>
|
{#if data.user?.role === 'admin'}
|
||||||
|
<a
|
||||||
|
href="/admin/scrape"
|
||||||
|
class="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="text-sm transition-colors {page.url.pathname.startsWith('/admin/audio') ? 'text-zinc-100 font-medium' : 'text-zinc-400 hover:text-zinc-100'}"
|
||||||
|
>
|
||||||
|
Audio cache
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<a
|
||||||
|
href="/profile"
|
||||||
|
class="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">
|
<form method="POST" action="/logout">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
17
ui/src/routes/admin/audio/+page.server.ts
Normal file
17
ui/src/routes/admin/audio/+page.server.ts
Normal 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 };
|
||||||
|
};
|
||||||
92
ui/src/routes/admin/audio/+page.svelte
Normal file
92
ui/src/routes/admin/audio/+page.svelte
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let entries = $state(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>
|
||||||
29
ui/src/routes/admin/scrape/+page.server.ts
Normal file
29
ui/src/routes/admin/scrape/+page.server.ts
Normal 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 };
|
||||||
|
};
|
||||||
195
ui/src/routes/admin/scrape/+page.svelte
Normal file
195
ui/src/routes/admin/scrape/+page.svelte
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
// ── Live-poll status ────────────────────────────────────────────────────────
|
||||||
|
let running = $state(data.running);
|
||||||
|
let tasks = $state(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-zinc-700/50">
|
||||||
|
{#each tasks as task}
|
||||||
|
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-zinc-300">
|
||||||
|
{task.kind}
|
||||||
|
{#if task.target_url}
|
||||||
|
<br />
|
||||||
|
<span class="text-zinc-500 truncate max-w-[16rem] block" title={task.target_url}>
|
||||||
|
{task.target_url.replace('https://novelfire.net/book/', '')}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="font-medium {statusColor(task.status)}">{task.status}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right text-zinc-300">{task.books_found ?? 0}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-zinc-300">{task.chapters_scraped ?? 0}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-zinc-400">{task.chapters_skipped ?? 0}</td>
|
||||||
|
<td class="px-4 py-3 text-right {task.errors > 0 ? 'text-red-400' : 'text-zinc-400'}">{task.errors ?? 0}</td>
|
||||||
|
<td class="px-4 py-3 text-zinc-400">{fmtDate(task.started)}</td>
|
||||||
|
<td class="px-4 py-3 text-zinc-400">{duration(task.started, task.finished)}</td>
|
||||||
|
</tr>
|
||||||
|
{#if task.error_message}
|
||||||
|
<tr class="bg-red-950/20">
|
||||||
|
<td colspan="8" class="px-4 py-2 text-xs text-red-400 font-mono">{task.error_message}</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
23
ui/src/routes/api/admin/scrape/+server.ts
Normal file
23
ui/src/routes/api/admin/scrape/+server.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
37
ui/src/routes/api/browse-page/+server.ts
Normal file
37
ui/src/routes/api/browse-page/+server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -18,6 +18,69 @@
|
|||||||
loadingSlug = slug;
|
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[]>(data.novels);
|
||||||
|
let currentPage = $state(data.page);
|
||||||
|
let hasNext = $state(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
|
// Filter options
|
||||||
const genres = [
|
const genres = [
|
||||||
{ value: 'all', label: 'All Genres' },
|
{ value: 'all', label: 'All Genres' },
|
||||||
@@ -55,16 +118,6 @@
|
|||||||
const isRankView = $derived(data.sort === 'rank');
|
const isRankView = $derived(data.sort === 'rank');
|
||||||
const isSearchView = $derived(!!data.searchQuery);
|
const isSearchView = $derived(!!data.searchQuery);
|
||||||
|
|
||||||
function buildURL(overrides: Record<string, string | number>) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: String(data.page),
|
|
||||||
genre: data.genre,
|
|
||||||
sort: data.sort,
|
|
||||||
status: data.status,
|
|
||||||
...Object.fromEntries(Object.entries(overrides).map(([k, v]) => [k, String(v)]))
|
|
||||||
});
|
|
||||||
return `/browse?${params.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// View toggle: 'grid' | 'list'. Default to 'list' when sort=rank (more detail).
|
// View toggle: 'grid' | 'list'. Default to 'list' when sort=rank (more detail).
|
||||||
let view = $state<'grid' | 'list'>(data.sort === 'rank' ? 'list' : 'grid');
|
let view = $state<'grid' | 'list'>(data.sort === 'rank' ? 'list' : 'grid');
|
||||||
@@ -111,13 +164,13 @@
|
|||||||
<h1 class="text-2xl font-bold text-zinc-100">Discover</h1>
|
<h1 class="text-2xl font-bold text-zinc-100">Discover</h1>
|
||||||
<p class="text-zinc-400 text-sm mt-1">
|
<p class="text-zinc-400 text-sm mt-1">
|
||||||
{#if isSearchView}
|
{#if isSearchView}
|
||||||
{data.novels.length} result{data.novels.length !== 1 ? 's' : ''} for "<span class="text-zinc-200">{data.searchQuery}</span>"
|
{novels.length} result{novels.length !== 1 ? 's' : ''} for "<span class="text-zinc-200">{data.searchQuery}</span>"
|
||||||
{#if data.searchLocalCount > 0 || data.searchRemoteCount > 0}
|
{#if data.searchLocalCount > 0 || data.searchRemoteCount > 0}
|
||||||
<span class="text-zinc-500 text-xs ml-1">({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire)</span>
|
<span class="text-zinc-500 text-xs ml-1">({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire)</span>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if isRankView}
|
{:else if isRankView}
|
||||||
{#if data.novels.length > 0}
|
{#if novels.length > 0}
|
||||||
{data.novels.length} novels ranked from last catalogue scrape
|
{novels.length} novels ranked from last catalogue scrape
|
||||||
{:else}
|
{:else}
|
||||||
No ranking data — run a full catalogue scrape to populate
|
No ranking data — run a full catalogue scrape to populate
|
||||||
{/if}
|
{/if}
|
||||||
@@ -273,7 +326,7 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
{#if data.novels.length === 0}
|
{#if novels.length === 0}
|
||||||
<div class="text-center py-20 text-zinc-500">
|
<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-lg">{isSearchView ? 'No results found.' : isRankView ? 'No ranking data.' : 'No novels found.'}</p>
|
||||||
<p class="text-sm mt-2">
|
<p class="text-sm mt-2">
|
||||||
@@ -294,7 +347,7 @@
|
|||||||
{:else if view === 'grid'}
|
{:else if view === 'grid'}
|
||||||
<!-- ── Grid view ─────────────────────────────────────────────────────── -->
|
<!-- ── 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">
|
<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.novels as novel}
|
{#each novels as novel}
|
||||||
{@const isLoading = loadingSlug === novel.slug}
|
{@const isLoading = loadingSlug === novel.slug}
|
||||||
<a
|
<a
|
||||||
href="/books/{novel.slug}"
|
href="/books/{novel.slug}"
|
||||||
@@ -379,7 +432,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- ── List view ─────────────────────────────────────────────────────── -->
|
<!-- ── List view ─────────────────────────────────────────────────────── -->
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{#each data.novels as novel}
|
{#each novels as novel}
|
||||||
{@const isLoading = loadingSlug === novel.slug}
|
{@const isLoading = loadingSlug === novel.slug}
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-4 bg-zinc-800 border rounded-lg px-4 py-3 transition-colors
|
class="flex items-center gap-4 bg-zinc-800 border rounded-lg px-4 py-3 transition-colors
|
||||||
@@ -487,25 +540,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Pagination (browse mode only) -->
|
<!-- Infinite scroll sentinel (browse mode only — not rank, not search) -->
|
||||||
{#if !isRankView && !isSearchView && data.novels.length > 0}
|
{#if !isRankView && !isSearchView}
|
||||||
<div class="flex items-center justify-center gap-3 mt-8">
|
{#if hasNext}
|
||||||
{#if data.page > 1}
|
<!-- Invisible div watched by IntersectionObserver -->
|
||||||
<a
|
<div bind:this={sentinel} class="h-px mt-8"></div>
|
||||||
href={buildURL({ page: data.page - 1 })}
|
{/if}
|
||||||
class="px-4 py-2 rounded bg-zinc-800 text-zinc-200 text-sm hover:bg-zinc-700 border border-zinc-700 transition-colors"
|
|
||||||
>
|
<!-- Loading spinner while fetching next page -->
|
||||||
Previous
|
{#if loadingMore}
|
||||||
</a>
|
<div class="flex justify-center py-8">
|
||||||
{/if}
|
<svg class="w-6 h-6 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
|
||||||
<span class="text-zinc-400 text-sm">Page {data.page}</span>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
{#if data.hasNext}
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
<a
|
</svg>
|
||||||
href={buildURL({ page: data.page + 1 })}
|
</div>
|
||||||
class="px-4 py-2 rounded bg-zinc-800 text-zinc-200 text-sm hover:bg-zinc-700 border border-zinc-700 transition-colors"
|
{:else if !hasNext && novels.length > 0}
|
||||||
>
|
<p class="text-center text-zinc-600 text-xs mt-8 pb-4">All novels loaded</p>
|
||||||
Next
|
{/if}
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
50
ui/src/routes/profile/+page.server.ts
Normal file
50
ui/src/routes/profile/+page.server.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { changePassword } from '$lib/server/pocketbase';
|
||||||
|
import { log } from '$lib/server/logger';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
|
if (!locals.user) {
|
||||||
|
redirect(302, '/login');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user: locals.user
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
224
ui/src/routes/profile/+page.svelte
Normal file
224
ui/src/routes/profile/+page.svelte
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import type { PageData, ActionData } from './$types';
|
||||||
|
import { audioStore } from '$lib/audio.svelte';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Profile — libnovel</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="max-w-xl mx-auto space-y-10">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-zinc-100">Profile</h1>
|
||||||
|
<p class="text-zinc-400 text-sm mt-1">Signed in as <span class="text-zinc-200 font-medium">{data.user.username}</span></p>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- ── 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>
|
||||||
Reference in New Issue
Block a user