feat(i18n): add Paraglide i18n with 5 locales (v2.3.22)
Some checks failed
CI / Backend (pull_request) Successful in 41s
CI / UI (pull_request) Failing after 27s
CI / UI (push) Failing after 27s
CI / Backend (push) Successful in 48s
Release / Test backend (push) Successful in 23s
Release / Check ui (push) Failing after 33s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Failing after 52s
Release / Docker / backend (push) Failing after 11s
Release / Docker / runner (push) Successful in 1m51s
Release / Gitea Release (push) Has been skipped
Some checks failed
CI / Backend (pull_request) Successful in 41s
CI / UI (pull_request) Failing after 27s
CI / UI (push) Failing after 27s
CI / Backend (push) Successful in 48s
Release / Test backend (push) Successful in 23s
Release / Check ui (push) Failing after 33s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Failing after 52s
Release / Docker / backend (push) Failing after 11s
Release / Docker / runner (push) Successful in 1m51s
Release / Gitea Release (push) Has been skipped
- Install @inlang/paraglide-js v2.15.1; configure project.inlang settings - Add en/ru/id/pt-BR/fr message catalogues (~140 keys each) - Wire paraglideVitePlugin in vite.config.ts, reroute hook in hooks.ts, and paraglideHandle middleware in hooks.server.ts - Migrate all routes and shared components to use m.*() message calls - Fix duplicate onMount body in chapters/[n]/+page.svelte - Build passes; svelte-check: 0 errors, 3 pre-existing warnings
This commit is contained in:
@@ -1,38 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
const status = $derived(page.status);
|
||||
const message = $derived(page.error?.message ?? 'Something went wrong.');
|
||||
|
||||
const title = $derived(
|
||||
status === 404
|
||||
? 'Page not found'
|
||||
: status === 403
|
||||
? 'Access denied'
|
||||
: status === 429
|
||||
? 'Too many requests'
|
||||
: status >= 500
|
||||
? 'Server error'
|
||||
: 'Error'
|
||||
? m.error_not_found_title()
|
||||
: m.error_generic_title()
|
||||
);
|
||||
|
||||
const description = $derived(
|
||||
status === 404
|
||||
? "The page you're looking for doesn't exist or has been moved."
|
||||
: status === 403
|
||||
? "You don't have permission to access this page."
|
||||
: status === 429
|
||||
? 'You are sending too many requests. Please slow down and try again shortly.'
|
||||
: status >= 500
|
||||
? 'An unexpected error occurred on our end. Try refreshing, or come back in a moment.'
|
||||
: message
|
||||
? m.error_not_found_body()
|
||||
: page.error?.message ?? m.error_generic_title()
|
||||
);
|
||||
|
||||
const code = $derived(String(status));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{status} — {title} · libnovel</title>
|
||||
<title>{m.error_status({ status: code })} · libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Full-viewport centred error page — no layout nav since this is +error.svelte -->
|
||||
@@ -56,13 +44,13 @@
|
||||
href="/"
|
||||
class="px-5 py-2.5 rounded-xl bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Go home
|
||||
{m.error_go_home()}
|
||||
</a>
|
||||
<button
|
||||
onclick={() => history.back()}
|
||||
class="px-5 py-2.5 rounded-xl bg-(--color-surface-2) border border-(--color-border) text-(--color-text) font-semibold text-sm hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
Go back
|
||||
{m.common_back()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/utils';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { locales, getLocale, localizeHref } from '$lib/paraglide/runtime.js';
|
||||
|
||||
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||
|
||||
@@ -260,16 +262,16 @@
|
||||
{#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-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
href="/books"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/books') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Library
|
||||
{m.nav_library()}
|
||||
</a>
|
||||
<a
|
||||
href="/catalogue"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Catalogue
|
||||
{m.nav_catalogue()}
|
||||
</a>
|
||||
<a
|
||||
href="https://feedback.libnovel.cc"
|
||||
@@ -277,7 +279,7 @@
|
||||
rel="noopener noreferrer"
|
||||
class="hidden sm:block text-sm transition-colors text-(--color-muted) hover:text-(--color-text)"
|
||||
>
|
||||
Feedback
|
||||
{m.nav_feedback()}
|
||||
</a>
|
||||
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
@@ -287,7 +289,7 @@
|
||||
href="/admin/scrape"
|
||||
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/admin') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>
|
||||
Admin
|
||||
{m.nav_admin()}
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
@@ -298,7 +300,7 @@
|
||||
</a>
|
||||
<form method="POST" action="/logout" class="hidden sm:block">
|
||||
<Button type="submit" variant="ghost" size="sm" class="text-(--color-muted) hover:text-(--color-text)">
|
||||
Sign out
|
||||
{m.nav_sign_out()}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -307,7 +309,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
aria-label={m.nav_toggle_menu()}
|
||||
aria-expanded={menuOpen}
|
||||
class="sm:hidden -mr-1"
|
||||
>
|
||||
@@ -330,7 +332,7 @@
|
||||
href="/login"
|
||||
class="text-sm px-3 py-1.5 rounded bg-(--color-brand) text-(--color-surface) font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Sign in
|
||||
{m.nav_sign_in()}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -344,14 +346,14 @@
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/books') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
Library
|
||||
{m.nav_library()}
|
||||
</a>
|
||||
<a
|
||||
href="/catalogue"
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/catalogue') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
Catalogue
|
||||
{m.nav_catalogue()}
|
||||
</a>
|
||||
<a
|
||||
href="https://feedback.libnovel.cc"
|
||||
@@ -360,24 +362,24 @@
|
||||
onclick={() => (menuOpen = false)}
|
||||
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)"
|
||||
>
|
||||
Feedback ↗
|
||||
{m.nav_feedback()} ↗
|
||||
</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-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
Profile <span class="text-(--color-muted) font-normal opacity-60">({data.user.username})</span>
|
||||
{m.nav_profile()} <span class="text-(--color-muted) font-normal opacity-60">({data.user.username})</span>
|
||||
</a>
|
||||
{#if data.user?.role === 'admin'}
|
||||
<div class="my-1 border-t border-(--color-border)/60"></div>
|
||||
<p class="px-3 pt-1 pb-0.5 text-xs text-(--color-muted) opacity-50 uppercase tracking-widest">Admin</p>
|
||||
<p class="px-3 pt-1 pb-0.5 text-xs text-(--color-muted) opacity-50 uppercase tracking-widest">{m.nav_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') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
|
||||
>
|
||||
Admin panel
|
||||
{m.nav_admin_panel()}
|
||||
</a>
|
||||
{/if}
|
||||
<div class="my-1 border-t border-(--color-border)/60"></div>
|
||||
@@ -387,7 +389,7 @@
|
||||
variant="ghost"
|
||||
class="w-full justify-start px-3 py-2.5 h-auto text-sm font-medium text-(--color-danger) hover:bg-(--color-surface-2) hover:text-(--color-danger)"
|
||||
>
|
||||
Sign out
|
||||
{m.nav_sign_out()}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -404,15 +406,15 @@
|
||||
<div class="max-w-6xl mx-auto px-4 py-6 flex flex-col items-center gap-4 text-xs text-(--color-muted)">
|
||||
<!-- 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-(--color-text) transition-colors">Library</a>
|
||||
<a href="/catalogue" class="hover:text-(--color-text) transition-colors">Catalogue</a>
|
||||
<a href="/books" class="hover:text-(--color-text) transition-colors">{m.footer_library()}</a>
|
||||
<a href="/catalogue" class="hover:text-(--color-text) transition-colors">{m.footer_catalogue()}</a>
|
||||
<a
|
||||
href="https://feedback.libnovel.cc"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-(--color-text) transition-colors flex items-center gap-1"
|
||||
>
|
||||
Feedback
|
||||
{m.footer_feedback()}
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
@@ -430,13 +432,29 @@
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</nav>
|
||||
<!-- Locale switcher (always visible) -->
|
||||
<div class="hidden sm:flex items-center gap-1 ml-2">
|
||||
{#each locales as locale}
|
||||
<a
|
||||
href={localizeHref(page.url.pathname, { locale })}
|
||||
data-sveltekit-reload
|
||||
class="px-1.5 py-0.5 rounded text-xs font-mono transition-colors {getLocale() === locale
|
||||
? 'text-(--color-brand) bg-(--color-brand)/10'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
aria-label="{m.locale_switcher_label()}: {locale}"
|
||||
>
|
||||
{locale.toUpperCase()}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
<!-- Bottom row: legal links + copyright -->
|
||||
<div class="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-(--color-muted)">
|
||||
<a href="/disclaimer" class="hover:text-(--color-text) transition-colors">Disclaimer</a>
|
||||
<a href="/privacy" class="hover:text-(--color-text) transition-colors">Privacy</a>
|
||||
<a href="/dmca" class="hover:text-(--color-text) transition-colors">DMCA</a>
|
||||
<span>© {new Date().getFullYear()} libnovel</span>
|
||||
<a href="/disclaimer" class="hover:text-(--color-text) transition-colors">{m.footer_disclaimer()}</a>
|
||||
<a href="/privacy" class="hover:text-(--color-text) transition-colors">{m.footer_privacy()}</a>
|
||||
<a href="/dmca" class="hover:text-(--color-text) transition-colors">{m.footer_dmca()}</a>
|
||||
<span>{m.footer_copyright({ year: String(new Date().getFullYear()) })}</span>
|
||||
</div>
|
||||
<!-- Build version / commit SHA / build time -->
|
||||
{#snippet buildTime()}
|
||||
@@ -473,12 +491,12 @@
|
||||
<div class="border-b border-(--color-border) bg-(--color-surface) 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-(--color-border) sticky top-0 bg-(--color-surface)">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Chapters</span>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.player_chapters()}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
aria-label="Close chapter list"
|
||||
aria-label={m.player_close_chapter_list()}
|
||||
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -497,7 +515,7 @@
|
||||
<span class="tabular-nums text-(--color-muted) opacity-60 w-8 shrink-0 text-right">
|
||||
{ch.number}
|
||||
</span>
|
||||
<span class="truncate">{ch.title || `Chapter ${ch.number}`}</span>
|
||||
<span class="truncate">{ch.title || m.player_chapter_n({ n: String(ch.number) })}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
@@ -538,8 +556,8 @@
|
||||
<button
|
||||
class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-(--color-surface-2) 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}
|
||||
aria-label={audioStore.chapters.length > 0 ? m.player_toggle_chapter_list() : undefined}
|
||||
title={audioStore.chapters.length > 0 ? m.player_chapter_list_label() : undefined}
|
||||
>
|
||||
{#if audioStore.chapterTitle}
|
||||
<p class="text-xs text-(--color-text) truncate leading-tight">{audioStore.chapterTitle}</p>
|
||||
@@ -549,14 +567,14 @@
|
||||
{/if}
|
||||
{#if audioStore.status === 'generating'}
|
||||
<p class="text-xs text-(--color-brand) leading-tight">
|
||||
Generating… {Math.round(audioStore.progress)}%
|
||||
{m.player_generating({ percent: String(Math.round(audioStore.progress)) })}
|
||||
</p>
|
||||
{:else if audioStore.status === 'ready'}
|
||||
<p class="text-xs text-(--color-muted) tabular-nums leading-tight">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
</p>
|
||||
{:else if audioStore.status === 'loading'}
|
||||
<p class="text-xs text-(--color-muted) leading-tight">Loading…</p>
|
||||
<p class="text-xs text-(--color-muted) leading-tight">{m.player_loading()}</p>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -566,8 +584,8 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={skipBack}
|
||||
title="Back 15s"
|
||||
aria-label="Rewind 15 seconds"
|
||||
title={m.player_back_15()}
|
||||
aria-label={m.player_rewind_15()}
|
||||
>
|
||||
<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"/>
|
||||
@@ -579,7 +597,7 @@
|
||||
<button
|
||||
onclick={togglePlay}
|
||||
class="w-10 h-10 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors flex-shrink-0"
|
||||
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
|
||||
aria-label={audioStore.isPlaying ? m.player_pause() : m.player_play()}
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
@@ -597,8 +615,8 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={skipForward}
|
||||
title="Forward 30s"
|
||||
aria-label="Skip 30 seconds"
|
||||
title={m.player_forward_30()}
|
||||
aria-label={m.player_skip_30()}
|
||||
>
|
||||
<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"/>
|
||||
@@ -610,8 +628,8 @@
|
||||
<button
|
||||
onclick={cycleSpeed}
|
||||
class="text-xs font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors px-2 py-1 rounded bg-(--color-surface-2) hover:bg-(--color-surface-3) flex-shrink-0 tabular-nums w-12 text-center"
|
||||
title="Change playback speed"
|
||||
aria-label="Playback speed {audioStore.speed}x"
|
||||
title={m.player_change_speed()}
|
||||
aria-label={m.player_speed_label({ speed: String(audioStore.speed) })}
|
||||
>
|
||||
{audioStore.speed}×
|
||||
</button>
|
||||
@@ -627,12 +645,12 @@
|
||||
)}
|
||||
title={audioStore.autoNext
|
||||
? audioStore.nextStatus === 'prefetched'
|
||||
? `Auto-next on — Ch.${audioStore.nextChapter} ready`
|
||||
? m.player_auto_next_ready({ n: String(audioStore.nextChapter) })
|
||||
: audioStore.nextStatus === 'prefetching'
|
||||
? `Auto-next on — preparing Ch.${audioStore.nextChapter}…`
|
||||
: 'Auto-next on'
|
||||
: 'Auto-next off'}
|
||||
aria-label="Auto-next {audioStore.autoNext ? 'on' : 'off'}"
|
||||
? m.player_auto_next_preparing({ n: String(audioStore.nextChapter) })
|
||||
: m.player_auto_next_on()
|
||||
: m.player_auto_next_off()}
|
||||
aria-label={m.player_auto_next_aria({ state: audioStore.autoNext ? m.common_on() : m.common_off() })}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<!-- "skip to end" / auto-advance icon -->
|
||||
@@ -659,8 +677,8 @@
|
||||
<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"
|
||||
title={m.player_go_to_chapter()}
|
||||
aria-label={m.player_go_to_chapter()}
|
||||
>
|
||||
{#if audioStore.cover}
|
||||
<img
|
||||
@@ -684,8 +702,8 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={dismiss}
|
||||
title="Close player"
|
||||
aria-label="Close player"
|
||||
title={m.player_close()}
|
||||
aria-label={m.player_close()}
|
||||
class="text-(--color-muted) hover:text-(--color-text) flex-shrink-0"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -16,22 +17,22 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>libnovel</title>
|
||||
<title>{m.home_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<div class="flex gap-6 mb-8 text-center">
|
||||
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
|
||||
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.totalBooks}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Books</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_books()}</p>
|
||||
</div>
|
||||
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
|
||||
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.totalChapters.toLocaleString()}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">Chapters</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_chapters()}</p>
|
||||
</div>
|
||||
<div class="flex-1 rounded-lg bg-(--color-surface-2) border border-(--color-border) py-4 px-6">
|
||||
<p class="text-2xl font-bold text-(--color-brand)">{data.stats.booksInProgress}</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">In progress</p>
|
||||
<p class="text-xs text-(--color-muted) mt-0.5">{m.home_stat_in_progress()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,8 +40,8 @@
|
||||
{#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-(--color-text)">Continue Reading</h2>
|
||||
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
|
||||
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_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 }}
|
||||
@@ -66,7 +67,7 @@
|
||||
{/if}
|
||||
<!-- Chapter badge overlay -->
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
|
||||
ch.{chapter}
|
||||
{m.home_chapter_badge({ n: String(chapter) })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
@@ -85,8 +86,8 @@
|
||||
{#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-(--color-text)">Recently Updated</h2>
|
||||
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
|
||||
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_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}
|
||||
@@ -137,13 +138,13 @@
|
||||
<!-- Empty state -->
|
||||
{#if data.continueReading.length === 0 && data.recentlyUpdated.length === 0}
|
||||
<div class="text-center py-20 text-(--color-muted)">
|
||||
<p class="text-lg font-semibold text-(--color-text) mb-2">Your library is empty</p>
|
||||
<p class="text-sm mb-6">Discover novels and scrape them into your library.</p>
|
||||
<p class="text-lg font-semibold text-(--color-text) mb-2">{m.home_empty_title()}</p>
|
||||
<p class="text-sm mb-6">{m.home_empty_body()}</p>
|
||||
<a
|
||||
href="/catalogue"
|
||||
class="inline-block px-6 py-3 bg-(--color-brand) text-(--color-surface) font-semibold rounded hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Discover Novels
|
||||
{m.home_discover_novels()}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -152,7 +153,7 @@
|
||||
{#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-(--color-text)">From People You Follow</h2>
|
||||
<h2 class="text-lg font-bold text-(--color-text)">{m.home_from_following()}</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 }}
|
||||
@@ -185,7 +186,7 @@
|
||||
{/if}
|
||||
<!-- Reader attribution -->
|
||||
<p class="text-xs text-(--color-muted) truncate mt-0.5">
|
||||
via <span class="text-amber-500/70">{readerUsername}</span>
|
||||
{m.home_via_reader({ username: readerUsername })}
|
||||
</p>
|
||||
{#if genres.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-auto pt-1">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
const internalLinks = [
|
||||
{ href: '/admin/scrape', label: 'Scrape' },
|
||||
@@ -27,7 +28,7 @@
|
||||
<aside class="w-48 shrink-0 border-r border-(--color-border) px-3 py-6 flex flex-col gap-6">
|
||||
<!-- Internal pages -->
|
||||
<div>
|
||||
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Pages</p>
|
||||
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_pages_label()}</p>
|
||||
<nav class="flex flex-col gap-0.5">
|
||||
{#each internalLinks as link}
|
||||
<a
|
||||
@@ -45,7 +46,7 @@
|
||||
|
||||
<!-- External tools -->
|
||||
<div>
|
||||
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">Tools</p>
|
||||
<p class="px-2 mb-2 text-xs font-semibold text-(--color-muted) uppercase tracking-widest">{m.admin_tools_label()}</p>
|
||||
<nav class="flex flex-col gap-0.5">
|
||||
{#each externalLinks as link}
|
||||
<a
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import type { AudioJob, AudioCacheEntry } from '$lib/server/pocketbase';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -94,13 +95,13 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Audio — libnovel admin</title>
|
||||
<title>{m.admin_audio_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">Audio</h1>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">{m.admin_audio_heading()}</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
{stats.total} job{stats.total !== 1 ? 's' : ''} ·
|
||||
<span class="text-green-400">{stats.done} done</span>
|
||||
@@ -142,13 +143,13 @@
|
||||
<input
|
||||
type="search"
|
||||
bind:value={jobsQ}
|
||||
placeholder="Filter by slug, voice or status…"
|
||||
placeholder={m.admin_audio_filter_jobs()}
|
||||
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
|
||||
{#if filteredJobs.length === 0}
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">
|
||||
{jobsQ.trim() ? 'No matching jobs.' : 'No audio jobs yet.'}
|
||||
{jobsQ.trim() ? m.admin_audio_no_matching_jobs() : m.admin_audio_no_jobs()}
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Desktop table -->
|
||||
@@ -218,13 +219,13 @@
|
||||
<input
|
||||
type="search"
|
||||
bind:value={cacheQ}
|
||||
placeholder="Filter by slug, chapter or voice…"
|
||||
placeholder={m.admin_audio_filter_cache()}
|
||||
class="w-full max-w-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
|
||||
{#if filteredCache.length === 0}
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">
|
||||
{cacheQ.trim() ? 'No results.' : 'Audio cache is empty.'}
|
||||
{cacheQ.trim() ? m.admin_audio_no_cache_results() : m.admin_audio_cache_empty()}
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Desktop table -->
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -11,19 +12,19 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Changelog — libnovel admin</title>
|
||||
<title>{m.admin_changelog_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6 max-w-2xl">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-xl font-semibold text-(--color-text) flex-1">Changelog</h1>
|
||||
<h1 class="text-xl font-semibold text-(--color-text) flex-1">{m.admin_changelog_heading()}</h1>
|
||||
<a
|
||||
href="https://gitea.kalekber.cc/kamil/libnovel/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors flex items-center gap-1"
|
||||
>
|
||||
Gitea releases
|
||||
{m.admin_changelog_gitea()}
|
||||
<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" />
|
||||
@@ -32,9 +33,9 @@
|
||||
</div>
|
||||
|
||||
{#if data.error}
|
||||
<p class="text-sm text-(--color-danger)">Could not load releases: {data.error}</p>
|
||||
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: data.error })}</p>
|
||||
{:else if data.releases.length === 0}
|
||||
<p class="text-sm text-(--color-muted) py-8 text-center">No releases found.</p>
|
||||
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
|
||||
{:else}
|
||||
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
|
||||
{#each data.releases as release}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import type { ScrapingTask } from '$lib/server/pocketbase';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -228,15 +229,15 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Scrape tasks — libnovel admin</title>
|
||||
<title>{m.admin_scrape_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h1 class="text-xl font-semibold text-(--color-text) flex-1">Scrape</h1>
|
||||
<h1 class="text-xl font-semibold text-(--color-text) flex-1">{m.admin_scrape_heading()}</h1>
|
||||
<span class="text-xs {running ? 'text-(--color-brand) animate-pulse' : 'text-green-500'}">
|
||||
● {running ? 'Running' : 'Idle'}
|
||||
● {running ? m.admin_scrape_status_running() : m.admin_scrape_status_idle()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -244,20 +245,20 @@
|
||||
<div class="divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
|
||||
<!-- Full catalogue -->
|
||||
<div class="flex items-center gap-4 px-4 py-3 bg-(--color-surface)">
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">Full catalogue</span>
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_full_catalogue()}</span>
|
||||
<button
|
||||
onclick={triggerCatalogueScrape}
|
||||
disabled={running || cataloguing}
|
||||
class="px-3 py-1.5 rounded-md bg-(--color-brand) text-(--color-surface) font-semibold text-xs hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cataloguing ? 'Queuing…' : running ? 'Running…' : 'Start scrape'}
|
||||
{cataloguing ? m.admin_scrape_queuing() : running ? m.admin_scrape_running() : m.admin_scrape_start()}
|
||||
</button>
|
||||
{#if catalogueError}<span class="text-xs text-(--color-danger)">{catalogueError}</span>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Single book -->
|
||||
<div id="book-form" class="flex items-center gap-3 px-4 py-3 bg-(--color-surface)">
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">Single book</span>
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_single_book()}</span>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={scrapeUrl}
|
||||
@@ -269,14 +270,14 @@
|
||||
disabled={!scrapeUrl.trim() || running || scraping}
|
||||
class="shrink-0 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{scraping ? 'Queuing…' : 'Scrape'}
|
||||
{scraping ? m.admin_scrape_queuing() : m.admin_scrape_submit()}
|
||||
</button>
|
||||
{#if scrapeError}<span class="text-xs text-(--color-danger)">{scrapeError}</span>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Range scrape -->
|
||||
<div id="range-form" class="flex items-center gap-3 px-4 py-3 bg-(--color-surface) flex-wrap">
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">Chapter range</span>
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_range()}</span>
|
||||
<input
|
||||
type="url"
|
||||
bind:value={rangeUrl}
|
||||
@@ -302,14 +303,14 @@
|
||||
disabled={!rangeUrl.trim() || rangeFrom === null || running || ranging}
|
||||
class="shrink-0 px-3 py-1.5 rounded-md bg-(--color-surface-3) text-(--color-text) font-medium text-xs hover:bg-zinc-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{ranging ? 'Queuing…' : 'Go'}
|
||||
{ranging ? m.admin_scrape_queuing() : 'Go'}
|
||||
</button>
|
||||
{#if rangeError}<span class="text-xs text-(--color-danger) w-full pl-40">{rangeError}</span>{/if}
|
||||
</div>
|
||||
|
||||
<!-- Quick genre chips -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 bg-(--color-surface) flex-wrap">
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">Quick genres</span>
|
||||
<span class="text-sm text-(--color-muted) w-36 shrink-0">{m.admin_scrape_quick_genres()}</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each quickScrapes as qs}
|
||||
<button
|
||||
@@ -334,18 +335,18 @@
|
||||
<!-- Tasks table -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) flex-1 uppercase tracking-widest">Task history</h2>
|
||||
<h2 class="text-sm font-semibold text-(--color-muted) flex-1 uppercase tracking-widest">{m.admin_scrape_task_history()}</h2>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={q}
|
||||
placeholder="Filter by kind, status or URL…"
|
||||
placeholder={m.admin_scrape_filter_placeholder()}
|
||||
class="w-full max-w-xs bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-text) text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-(--color-brand)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">
|
||||
{q.trim() ? 'No matching tasks.' : 'No scrape tasks yet.'}
|
||||
{q.trim() ? m.admin_scrape_no_matching() : m.admin_tasks_empty()}
|
||||
</p>
|
||||
{:else}
|
||||
<!-- Desktop table -->
|
||||
@@ -393,7 +394,7 @@
|
||||
disabled={cancellingIds.has(task.id)}
|
||||
class="px-2 py-1 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-text) hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel'}
|
||||
{cancellingIds.has(task.id) ? 'Cancelling…' : m.admin_scrape_cancel()}
|
||||
</button>
|
||||
{/if}
|
||||
{#if task.kind === 'book_range' && task.status !== 'pending' && task.status !== 'running' && (task.chapters_scraped ?? 0) > 0}
|
||||
@@ -461,7 +462,7 @@
|
||||
disabled={cancellingIds.has(task.id)}
|
||||
class="flex-1 px-3 py-1.5 rounded-lg text-xs font-medium bg-(--color-surface-3) text-(--color-text) hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel task'}
|
||||
{cancellingIds.has(task.id) ? 'Cancelling…' : m.admin_scrape_cancel()} task
|
||||
</button>
|
||||
{/if}
|
||||
{#if task.kind === 'book_range' && task.status !== 'pending' && task.status !== 'running' && (task.chapters_scraped ?? 0) > 0}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -16,23 +17,23 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Library — libnovel</title>
|
||||
<title>{m.books_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">Library</h1>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">{m.books_heading()}</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
{data.books?.length ?? 0} book{(data.books?.length ?? 0) !== 1 ? 's' : ''}
|
||||
{m.books_count({ n: String(data.books?.length ?? 0), s: (data.books?.length ?? 0) !== 1 ? 's' : '' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !data.books?.length}
|
||||
<div class="text-center py-20 text-(--color-muted)">
|
||||
<p class="text-lg">Your library is empty.</p>
|
||||
<p class="text-lg">{m.books_empty_library()}</p>
|
||||
<p class="text-sm mt-2">
|
||||
Books you start reading or save from
|
||||
<a href="/catalogue" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">Discover</a>
|
||||
will appear here.
|
||||
{m.books_empty_discover()}
|
||||
<a href="/catalogue" class="text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">{m.books_empty_discover_link()}</a>
|
||||
{m.books_empty_discover_suffix()}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -38,8 +39,6 @@
|
||||
}
|
||||
|
||||
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 ───────────────────────────────────────────────────────
|
||||
@@ -102,9 +101,6 @@
|
||||
let adminOpen = $state(false);
|
||||
|
||||
// ── Auto-poll when scrape task is in flight ───────────────────────────────
|
||||
// When the backend enqueues a scrape for an unseen book, the page shows a
|
||||
// spinner. Poll every 3 s until the task reaches "done" or "failed", then
|
||||
// reload the full page data so chapters appear automatically.
|
||||
$effect(() => {
|
||||
if (!data.scraping || !data.taskId) return;
|
||||
|
||||
@@ -130,7 +126,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.scraping ? 'Scraping…' : data.book?.title ?? 'Book'} — libnovel</title>
|
||||
<title>{data.scraping ? m.book_detail_scraping() : (data.book?.title ?? 'Book')} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if data.scraping}
|
||||
@@ -141,15 +137,15 @@
|
||||
<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-(--color-text) font-semibold text-lg">Scraping in progress…</p>
|
||||
<p class="text-(--color-text) font-semibold text-lg">{m.book_detail_scraping()}</p>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
Fetching the first 20 chapters. This page will refresh automatically.
|
||||
{m.book_detail_scraping_progress()}
|
||||
</p>
|
||||
{#if data.taskId}
|
||||
<p class="text-(--color-muted) text-xs mt-2 font-mono">task: {data.taskId}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<a href="/" class="mt-2 text-sm text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">← Home</a>
|
||||
<a href="/" class="mt-2 text-sm text-(--color-brand) hover:text-(--color-brand-dim) transition-colors">{m.book_detail_scraping_home()}</a>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
@@ -189,7 +185,7 @@
|
||||
class="mt-1 text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted) border border-(--color-border) shrink-0"
|
||||
title="This book was fetched live from the source and is not yet in your library"
|
||||
>
|
||||
not in library
|
||||
{m.book_detail_not_in_library()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -220,20 +216,20 @@
|
||||
onclick={() => (summaryExpanded = !summaryExpanded)}
|
||||
class="text-xs text-(--color-brand)/70 hover:text-(--color-brand) mt-1 transition-colors"
|
||||
>
|
||||
{summaryExpanded ? 'Less' : 'More'}
|
||||
{summaryExpanded ? m.book_detail_less() : m.book_detail_more()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- CTA buttons — desktop only (hidden on mobile, shown below on mobile) -->
|
||||
<!-- CTA buttons — desktop only -->
|
||||
<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-(--color-brand) text-(--color-surface) font-semibold rounded-lg text-sm hover:bg-(--color-brand-dim) transition-colors shadow"
|
||||
>
|
||||
Continue ch.{data.lastChapter}
|
||||
{m.book_detail_continue_ch({ n: String(data.lastChapter) })}
|
||||
</a>
|
||||
{/if}
|
||||
{#if chapterList.length > 0}
|
||||
@@ -244,14 +240,14 @@
|
||||
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
|
||||
: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
|
||||
>
|
||||
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
|
||||
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.inLib}
|
||||
<button
|
||||
onclick={toggleSave}
|
||||
disabled={saving}
|
||||
title={saved ? 'Remove from library' : 'Add to library'}
|
||||
title={saved ? m.book_detail_remove_from_library() : m.book_detail_add_to_library()}
|
||||
class="flex items-center justify-center w-9 h-9 rounded-lg border transition-colors disabled:opacity-50
|
||||
{saved
|
||||
? 'bg-(--color-brand)/20 text-(--color-brand-dim) border-(--color-brand)/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
|
||||
@@ -277,14 +273,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA buttons — mobile only, full-width row below cover+meta -->
|
||||
<!-- CTA buttons — mobile only -->
|
||||
<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-(--color-brand) text-(--color-surface) font-semibold rounded-lg text-sm hover:bg-(--color-brand-dim) transition-colors shadow"
|
||||
>
|
||||
Continue ch.{data.lastChapter}
|
||||
{m.book_detail_continue_ch({ n: String(data.lastChapter) })}
|
||||
</a>
|
||||
{/if}
|
||||
{#if chapterList.length > 0}
|
||||
@@ -295,14 +291,14 @@
|
||||
? 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-3)'
|
||||
: 'bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) shadow'}"
|
||||
>
|
||||
{data.inLib ? 'Start from ch.1' : 'Preview ch.1'}
|
||||
{data.inLib ? m.book_detail_start_ch1() : m.book_detail_preview_ch1()}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.inLib}
|
||||
<button
|
||||
onclick={toggleSave}
|
||||
disabled={saving}
|
||||
title={saved ? 'Remove from library' : 'Add to library'}
|
||||
title={saved ? m.book_detail_remove_from_library() : m.book_detail_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-(--color-brand)/20 text-(--color-brand-dim) border-(--color-brand)/30 hover:bg-red-500/20 hover:text-red-300 hover:border-red-400/30'
|
||||
@@ -330,7 +326,6 @@
|
||||
|
||||
<!-- ══════════════════════════════════════════════════ Chapters row ══ -->
|
||||
<div class="flex flex-col divide-y divide-(--color-border) border border-(--color-border) 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-(--color-surface-2)/60 transition-colors group"
|
||||
@@ -339,13 +334,13 @@
|
||||
<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-(--color-text)">Chapters</span>
|
||||
<span class="text-sm font-semibold text-(--color-text)">{m.chapters_heading()}</span>
|
||||
{#if chapterList.length > 0}
|
||||
<span class="text-xs text-(--color-muted)">
|
||||
{#if data.lastChapter && data.lastChapter > 0}
|
||||
Reading ch.{data.lastChapter} of {chapterList.length}
|
||||
{m.book_detail_reading_ch({ n: String(data.lastChapter), total: String(chapterList.length) })}
|
||||
{:else}
|
||||
{chapterList.length} chapter{chapterList.length === 1 ? '' : 's'}
|
||||
{m.book_detail_n_chapters({ n: String(chapterList.length) })}
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -366,7 +361,7 @@
|
||||
<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
|
||||
{m.book_detail_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>
|
||||
@@ -387,17 +382,17 @@
|
||||
<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…
|
||||
{m.book_detail_rescraping()}
|
||||
{: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
|
||||
{m.book_detail_rescrape_book()}
|
||||
{/if}
|
||||
</button>
|
||||
{#if scrapeResult}
|
||||
<span class="text-xs {scrapeResult === 'queued' ? 'text-green-400' : scrapeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
|
||||
{scrapeResult === 'queued' ? 'Queued.' : scrapeResult === 'busy' ? 'Scraper busy.' : 'Error.'}
|
||||
{scrapeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : scrapeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -405,7 +400,7 @@
|
||||
<!-- 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-(--color-muted)">From chapter</label>
|
||||
<label for="range-from" class="text-xs text-(--color-muted)">{m.book_detail_from_chapter()}</label>
|
||||
<input
|
||||
id="range-from"
|
||||
type="number"
|
||||
@@ -416,7 +411,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="range-to" class="text-xs text-(--color-muted)">To chapter (optional)</label>
|
||||
<label for="range-to" class="text-xs text-(--color-muted)">{m.book_detail_to_chapter()}</label>
|
||||
<input
|
||||
id="range-to"
|
||||
type="number"
|
||||
@@ -434,11 +429,11 @@
|
||||
? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed'
|
||||
: 'bg-(--color-brand)/20 text-(--color-brand-dim) hover:bg-(--color-brand)/40 border border-(--color-brand)/30'}"
|
||||
>
|
||||
{rangeScraping ? 'Queuing…' : 'Scrape range'}
|
||||
{rangeScraping ? m.book_detail_range_queuing() : m.book_detail_scrape_range()}
|
||||
</button>
|
||||
{#if rangeResult}
|
||||
<span class="text-xs {rangeResult === 'queued' ? 'text-green-400' : rangeResult === 'busy' ? 'text-(--color-brand)' : 'text-(--color-danger)'}">
|
||||
{rangeResult === 'queued' ? 'Range scrape queued.' : rangeResult === 'busy' ? 'Scraper busy.' : 'Error queuing.'}
|
||||
{rangeResult === 'queued' ? m.catalogue_scrape_queued_badge() + '.' : rangeResult === 'busy' ? m.catalogue_scrape_busy_badge() + '.' : m.common_error() + '.'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import type { ChapterIdx } from '$lib/server/pocketbase';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -57,7 +58,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.book.title} — Chapters — libnovel</title>
|
||||
<title>{m.chapters_page_title({ title: data.book.title })}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- ── Back link + title ─────────────────────────────────────────────────── -->
|
||||
@@ -69,7 +70,7 @@
|
||||
<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
|
||||
{m.common_back()}
|
||||
</a>
|
||||
<span class="text-(--color-border)">/</span>
|
||||
<h1 class="text-base font-semibold text-(--color-text) truncate">{data.book.title}</h1>
|
||||
@@ -82,7 +83,7 @@
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search chapters…"
|
||||
placeholder={m.chapters_search_placeholder()}
|
||||
bind:value={searchQuery}
|
||||
class="w-full pl-9 pr-4 py-2.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-text) placeholder-zinc-500 text-sm focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
@@ -126,21 +127,21 @@
|
||||
<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}
|
||||
{m.chapters_jump_to({ n: String(data.lastChapter) })}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
|
||||
{#if visibleChapters.length === 0}
|
||||
{#if searchQuery}
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">No chapters match "{searchQuery}"</p>
|
||||
<p class="text-(--color-muted) text-sm py-8 text-center">{m.chapters_no_match({ q: searchQuery })}</p>
|
||||
{:else}
|
||||
<p class="text-(--color-muted) text-sm">No chapters available yet.</p>
|
||||
<p class="text-(--color-muted) text-sm">{m.chapters_none_available()}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Result count while searching -->
|
||||
{#if searchQuery}
|
||||
<p class="text-xs text-(--color-muted) mb-2">{visibleChapters.length} result{visibleChapters.length === 1 ? '' : 's'}</p>
|
||||
<p class="text-xs text-(--color-muted) mb-2">{m.chapters_result_count({ n: String(visibleChapters.length) })}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
@@ -165,7 +166,7 @@
|
||||
class="flex-1 min-w-0 text-sm truncate transition-colors
|
||||
{isCurrent ? 'text-(--color-brand-dim) font-medium' : 'text-(--color-text) group-hover:text-(--color-text)'}"
|
||||
>
|
||||
{chapter.title || `Chapter ${chapter.number}`}
|
||||
{chapter.title || m.reader_chapter_n({ n: String(chapter.number) })}
|
||||
</span>
|
||||
|
||||
<!-- Date — desktop only -->
|
||||
@@ -177,7 +178,7 @@
|
||||
|
||||
<!-- Reading indicator -->
|
||||
{#if isCurrent}
|
||||
<span class="text-xs text-(--color-brand) font-medium flex-shrink-0">reading</span>
|
||||
<span class="text-xs text-(--color-brand) font-medium flex-shrink-0">{m.chapters_reading_indicator()}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import AudioPlayer from '$lib/components/AudioPlayer.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -9,15 +10,6 @@
|
||||
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 () => {
|
||||
// Umami analytics: track chapter reads
|
||||
window.umami?.track('chapter_read', {
|
||||
@@ -50,31 +42,35 @@
|
||||
const { marked } = await import('marked');
|
||||
html = await marked(d.text, { async: true });
|
||||
} else {
|
||||
fetchError = 'Chapter content not available.';
|
||||
fetchError = m.reader_audio_error();
|
||||
}
|
||||
} catch (e) {
|
||||
fetchError = 'Could not fetch chapter content.';
|
||||
fetchError = m.reader_fetching_chapter();
|
||||
} finally {
|
||||
fetchingContent = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const wordCount = $derived(
|
||||
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.chapter.title || `Chapter ${data.chapter.number}`} — {data.book.title} — libnovel</title>
|
||||
<title>{data.chapter.title || m.reader_chapter_n({ n: String(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}"
|
||||
href="/books/{data.book.slug}/chapters"
|
||||
class="text-(--color-muted) hover:text-(--color-text) 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
|
||||
{m.reader_back_to_chapters()}
|
||||
</a>
|
||||
|
||||
<div class="flex gap-2">
|
||||
@@ -83,7 +79,7 @@
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
← Ch.{data.prev}
|
||||
← {m.reader_chapter_n({ n: String(data.prev) })}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.next}
|
||||
@@ -91,7 +87,7 @@
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="px-3 py-1.5 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Ch.{data.next} →
|
||||
{m.reader_chapter_n({ n: String(data.next) })} →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -100,10 +96,10 @@
|
||||
<!-- Chapter heading -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-xl font-bold text-(--color-text)">
|
||||
{data.chapter.title || `Chapter ${data.chapter.number}`}
|
||||
{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
</h1>
|
||||
{#if wordCount > 0}
|
||||
<p class="text-(--color-muted) text-xs mt-1">{wordCount.toLocaleString()} words</p>
|
||||
<p class="text-(--color-muted) text-xs mt-1">{m.reader_words({ n: wordCount.toLocaleString() })}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -112,7 +108,7 @@
|
||||
<AudioPlayer
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
chapterTitle={data.chapter.title || `Chapter ${data.chapter.number}`}
|
||||
chapterTitle={data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
bookTitle={data.book.title}
|
||||
cover={data.book.cover}
|
||||
nextChapter={data.next}
|
||||
@@ -121,7 +117,7 @@
|
||||
/>
|
||||
{:else}
|
||||
<div class="mb-6 px-4 py-3 rounded bg-(--color-surface-2)/60 border border-(--color-border) text-(--color-muted) text-sm">
|
||||
Preview chapter — audio not available for books outside the library.
|
||||
{m.reader_preview_audio_notice()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -132,11 +128,11 @@
|
||||
<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…
|
||||
{m.reader_fetching_chapter()}
|
||||
</div>
|
||||
{:else if !html}
|
||||
<div class="text-(--color-muted) text-center py-16">
|
||||
<p>{fetchError || 'Chapter content not available.'}</p>
|
||||
<p>{fetchError || m.reader_audio_error()}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="prose-chapter mt-8">
|
||||
@@ -151,7 +147,7 @@
|
||||
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
← Previous chapter
|
||||
← {m.reader_prev_chapter()}
|
||||
</a>
|
||||
{:else}
|
||||
<div></div>
|
||||
@@ -161,7 +157,7 @@
|
||||
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||
class="px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Next chapter →
|
||||
{m.reader_next_chapter()} →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -5,17 +5,15 @@
|
||||
import { untrack } from 'svelte';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import type { NovelListing } from './+page.server';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
// ── Local filter state (mirrors URL params) ──────────────────────────────
|
||||
// These are separate from data.* so we can bind them to selects and keep
|
||||
// the DOM in sync. They sync back from data whenever a navigation completes.
|
||||
let filterSort = $state(untrack(() => data.sort));
|
||||
let filterGenre = $state(untrack(() => data.genre));
|
||||
let filterStatus = $state(untrack(() => data.status));
|
||||
|
||||
// Keep local state in sync whenever SvelteKit re-runs the load (URL changed).
|
||||
$effect(() => {
|
||||
filterSort = data.sort;
|
||||
filterGenre = data.genre;
|
||||
@@ -31,10 +29,8 @@
|
||||
goto(`/catalogue?${params.toString()}`);
|
||||
}
|
||||
|
||||
// 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;
|
||||
});
|
||||
@@ -44,15 +40,11 @@
|
||||
}
|
||||
|
||||
// ── 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(() => {
|
||||
@@ -66,7 +58,6 @@
|
||||
|
||||
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;
|
||||
@@ -106,44 +97,38 @@
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
// Filter options — built from Meilisearch facet distribution when available,
|
||||
// with a hardcoded fallback list for when Meilisearch is not yet populated.
|
||||
const FALLBACK_GENRES = [
|
||||
'action', 'adventure', 'comedy', 'drama', 'fantasy', 'harem',
|
||||
'historical', 'horror', 'isekai', 'martial-arts', 'mystery',
|
||||
'psychological', 'romance', 'sci-fi', 'system', 'xianxia'
|
||||
];
|
||||
const genres = $derived([
|
||||
{ value: 'all', label: 'All Genres' },
|
||||
{ value: 'all', label: m.catalogue_genre_all() },
|
||||
...((data.genres?.length ? data.genres : FALLBACK_GENRES).map((g: string) => ({
|
||||
value: g,
|
||||
label: g.charAt(0).toUpperCase() + g.slice(1).replace(/-/g, ' ')
|
||||
})))
|
||||
]);
|
||||
const sorts = [
|
||||
{ value: 'popular', label: 'Popular' },
|
||||
{ value: 'new', label: 'New' },
|
||||
{ value: 'update', label: 'Updated' },
|
||||
{ value: 'top-rated', label: 'Top Rated' },
|
||||
{ value: 'rank', label: 'Ranking' }
|
||||
];
|
||||
const sorts = $derived([
|
||||
{ value: 'popular', label: m.catalogue_sort_popular() },
|
||||
{ value: 'new', label: m.catalogue_sort_new() },
|
||||
{ value: 'update', label: m.catalogue_sort_updated() },
|
||||
{ value: 'top-rated', label: m.catalogue_sort_top_rated() },
|
||||
{ value: 'rank', label: m.catalogue_sort_rank() }
|
||||
]);
|
||||
const FALLBACK_STATUSES = ['ongoing', 'completed'];
|
||||
const statuses = $derived([
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'all', label: m.catalogue_status_all() },
|
||||
...((data.statuses?.length ? data.statuses : FALLBACK_STATUSES).map((s: string) => ({
|
||||
value: s,
|
||||
label: s.charAt(0).toUpperCase() + s.slice(1)
|
||||
})))
|
||||
]);
|
||||
|
||||
// When sort=rank the ranking API is used — pagination + genre/status filters
|
||||
// don't apply to that endpoint.
|
||||
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';
|
||||
@@ -154,7 +139,6 @@
|
||||
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';
|
||||
});
|
||||
@@ -194,7 +178,6 @@
|
||||
// ── 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;
|
||||
@@ -210,7 +193,6 @@
|
||||
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') ||
|
||||
@@ -229,26 +211,26 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Catalogue — libnovel</title>
|
||||
<title>{m.catalogue_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-4">
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">Catalogue</h1>
|
||||
<h1 class="text-2xl font-bold text-(--color-text)">{m.catalogue_heading()}</h1>
|
||||
<p class="text-(--color-muted) text-sm mt-1">
|
||||
{#if isSearchView}
|
||||
{novels.length} result{novels.length !== 1 ? 's' : ''} for "<span class="text-(--color-text)">{data.searchQuery}</span>"
|
||||
{m.catalogue_search_results({ n: String(novels.length), s: novels.length !== 1 ? 's' : '', q: data.searchQuery })}
|
||||
{#if data.searchLocalCount > 0 || data.searchRemoteCount > 0}
|
||||
<span class="text-(--color-muted) text-xs ml-1">({data.searchLocalCount} local, {data.searchRemoteCount} from novelfire)</span>
|
||||
<span class="text-(--color-muted) text-xs ml-1">{m.catalogue_search_local_count({ local: String(data.searchLocalCount), remote: String(data.searchRemoteCount) })}</span>
|
||||
{/if}
|
||||
{:else if isRankView}
|
||||
{#if novels.length > 0}
|
||||
{novels.length} novels ranked from last catalogue scrape
|
||||
{m.catalogue_rank_ranked({ n: String(novels.length) })}
|
||||
{:else}
|
||||
No ranking data — run a full catalogue scrape to populate
|
||||
{m.catalogue_rank_no_data_body()}
|
||||
{/if}
|
||||
{:else}
|
||||
Browse novels from novelfire.net
|
||||
{m.catalogue_browse_source()}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -257,15 +239,15 @@
|
||||
{#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.
|
||||
{m.catalogue_scrape_queued_flash()}
|
||||
</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.
|
||||
{m.catalogue_scrape_busy_flash()}
|
||||
</div>
|
||||
{:else if form.status === 'error'}
|
||||
<div class="mb-4 px-4 py-3 rounded bg-(--color-danger)/10 border border-(--color-danger) text-(--color-danger) text-sm">
|
||||
Failed to queue scrape. Check that the scraper service is reachable.
|
||||
{m.catalogue_scrape_error_flash()}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -278,21 +260,21 @@
|
||||
type="search"
|
||||
name="q"
|
||||
value={data.searchQuery}
|
||||
placeholder="Search…"
|
||||
placeholder={m.catalogue_search_placeholder()}
|
||||
class="flex-1 min-w-0 bg-(--color-surface-2) border border-(--color-border) text-(--color-text) text-sm rounded px-3 py-2 focus:outline-none focus:border-(--color-brand) placeholder-zinc-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-3 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors whitespace-nowrap"
|
||||
>
|
||||
Search
|
||||
{m.catalogue_search_button()}
|
||||
</button>
|
||||
{#if data.searchQuery}
|
||||
<a
|
||||
href="/catalogue"
|
||||
class="px-3 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors whitespace-nowrap"
|
||||
>
|
||||
Clear
|
||||
{m.catalogue_clear_filters()}
|
||||
</a>
|
||||
{/if}
|
||||
</form>
|
||||
@@ -311,8 +293,7 @@
|
||||
<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 -->
|
||||
<span class="hidden sm:inline">{m.catalogue_filters_label()}</span>
|
||||
{#if hasActiveFilters}
|
||||
<span class="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-(--color-brand)"></span>
|
||||
{/if}
|
||||
@@ -322,7 +303,7 @@
|
||||
<div class="flex items-center bg-(--color-surface-2) border border-(--color-border) rounded overflow-hidden shrink-0">
|
||||
<button
|
||||
onclick={() => (view = 'grid')}
|
||||
title="Grid view"
|
||||
title={m.catalogue_view_grid()}
|
||||
class="px-2.5 py-2 transition-colors {view === 'grid'
|
||||
? 'bg-(--color-surface-3) text-(--color-text)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
@@ -334,7 +315,7 @@
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (view = 'list')}
|
||||
title="List view"
|
||||
title={m.catalogue_view_list()}
|
||||
class="px-2.5 py-2 transition-colors {view === 'list'
|
||||
? 'bg-(--color-surface-3) text-(--color-text)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
@@ -364,7 +345,7 @@
|
||||
disabled={refreshing}
|
||||
class="hidden sm:block px-3 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
{refreshing ? 'Queuing…' : 'Refresh'}
|
||||
{refreshing ? m.catalogue_refreshing() : m.catalogue_refresh()}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
@@ -374,13 +355,13 @@
|
||||
{#if !filtersOpen && hasActiveFilters}
|
||||
<p class="text-xs text-(--color-muted) mb-3">
|
||||
<span class="text-(--color-muted)">{filterSummary}</span>
|
||||
<a href="/catalogue" class="ml-2 text-(--color-muted) hover:text-(--color-muted) underline underline-offset-2">clear</a>
|
||||
<a href="/catalogue" class="ml-2 text-(--color-muted) hover:text-(--color-muted) underline underline-offset-2">{m.catalogue_clear_filters().toLowerCase()}</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Collapsible filter panel -->
|
||||
{#if filtersOpen}
|
||||
<!-- Admin refresh (mobile only — outside filter form to avoid nested <form>) -->
|
||||
<!-- Admin refresh (mobile only) -->
|
||||
{#if data.isAdmin}
|
||||
<form
|
||||
method="POST"
|
||||
@@ -399,7 +380,7 @@
|
||||
disabled={refreshing}
|
||||
class="w-full px-3 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{refreshing ? 'Queuing…' : 'Refresh catalogue'}
|
||||
{refreshing ? m.catalogue_refreshing() : m.catalogue_refresh_mobile()}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
@@ -409,7 +390,7 @@
|
||||
|
||||
<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-(--color-muted) uppercase tracking-wide">Sort</label>
|
||||
<label for="filter-sort" class="text-xs text-(--color-muted) uppercase tracking-wide">{m.catalogue_filter_sort()}</label>
|
||||
<select
|
||||
id="filter-sort"
|
||||
name="sort"
|
||||
@@ -423,7 +404,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="filter-genre" class="text-xs text-(--color-muted) uppercase tracking-wide">Genre</label>
|
||||
<label for="filter-genre" class="text-xs text-(--color-muted) uppercase tracking-wide">{m.catalogue_filter_genre()}</label>
|
||||
<select
|
||||
id="filter-genre"
|
||||
name="genre"
|
||||
@@ -438,7 +419,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="filter-status" class="text-xs text-(--color-muted) uppercase tracking-wide">Status</label>
|
||||
<label for="filter-status" class="text-xs text-(--color-muted) uppercase tracking-wide">{m.catalogue_filter_status()}</label>
|
||||
<select
|
||||
id="filter-status"
|
||||
name="status"
|
||||
@@ -454,19 +435,19 @@
|
||||
</div>
|
||||
|
||||
{#if isRankView}
|
||||
<p class="text-xs text-(--color-muted) italic">Genre & status filters apply to Browse only</p>
|
||||
<p class="text-xs text-(--color-muted) italic">{m.catalogue_filter_rank_note()}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<a href="/catalogue" class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-3) transition-colors">
|
||||
Reset
|
||||
{m.catalogue_reset()}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={applyFilters}
|
||||
class="px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Apply
|
||||
{m.catalogue_apply()}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -475,18 +456,18 @@
|
||||
<!-- Content -->
|
||||
{#if novels.length === 0}
|
||||
<div class="text-center py-20 text-(--color-muted)">
|
||||
<p class="text-lg">{isSearchView ? 'No results found.' : isRankView ? 'No ranking data.' : 'No novels found.'}</p>
|
||||
<p class="text-lg">{isSearchView ? m.catalogue_no_results_search() : isRankView ? m.catalogue_rank_no_data() : m.catalogue_no_results()}</p>
|
||||
<p class="text-sm mt-2">
|
||||
{#if isSearchView}
|
||||
Try a different search term.
|
||||
{m.catalogue_no_results_try()}
|
||||
{:else if isRankView}
|
||||
{#if data.isAdmin}
|
||||
Click <span class="text-(--color-brand)">Refresh catalogue</span> above to trigger a full catalogue scrape.
|
||||
{m.catalogue_rank_run_scrape_admin()}
|
||||
{:else}
|
||||
Ask an admin to run a catalogue scrape.
|
||||
{m.catalogue_rank_run_scrape_user()}
|
||||
{/if}
|
||||
{:else}
|
||||
Try different filters or check back later.
|
||||
{m.catalogue_no_results_filters()}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -553,20 +534,20 @@
|
||||
{#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>
|
||||
<span class="text-xs text-emerald-400 font-medium">{m.catalogue_scrape_queued_badge()}</span>
|
||||
{:else if scrapeResult[novel.slug] === 'busy'}
|
||||
<span class="text-xs text-yellow-400 font-medium">Scraper busy</span>
|
||||
<span class="text-xs text-yellow-400 font-medium">{m.catalogue_scrape_busy_badge()}</span>
|
||||
{:else if scrapeResult[novel.slug] === 'forbidden'}
|
||||
<span class="text-xs text-(--color-danger) font-medium">Forbidden</span>
|
||||
<span class="text-xs text-(--color-danger) font-medium">{m.catalogue_scrape_forbidden_badge()}</span>
|
||||
{:else if scrapeResult[novel.slug] === 'error'}
|
||||
<span class="text-xs text-(--color-danger) font-medium">Error</span>
|
||||
<span class="text-xs text-(--color-danger) font-medium">{m.common_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-(--color-brand-dim) hover:bg-amber-500/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed border border-amber-500/30"
|
||||
>
|
||||
{scraping[novel.slug] ? 'Scraping…' : 'Scrape'}
|
||||
{scraping[novel.slug] ? m.catalogue_scraping_novel() : m.catalogue_scrape_novel_button()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -650,18 +631,18 @@
|
||||
{#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>
|
||||
<span class="text-xs text-emerald-400 font-medium">{m.catalogue_scrape_queued_badge()}</span>
|
||||
{:else if scrapeResult[novel.slug] === 'busy'}
|
||||
<span class="text-xs text-yellow-400 font-medium">Busy</span>
|
||||
<span class="text-xs text-yellow-400 font-medium">{m.catalogue_scrape_busy_list()}</span>
|
||||
{:else if scrapeResult[novel.slug] === 'error'}
|
||||
<span class="text-xs text-(--color-danger) font-medium">Error</span>
|
||||
<span class="text-xs text-(--color-danger) font-medium">{m.common_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-(--color-brand-dim) 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'}
|
||||
{scraping[novel.slug] ? m.catalogue_scraping_novel() : m.catalogue_scrape_novel_button()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -690,11 +671,9 @@
|
||||
<!-- 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-(--color-brand)" fill="none" viewBox="0 0 24 24">
|
||||
@@ -703,7 +682,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
{:else if !hasNext && novels.length > 0}
|
||||
<p class="text-center text-(--color-muted) text-xs mt-8 pb-4">All novels loaded</p>
|
||||
<p class="text-center text-(--color-muted) text-xs mt-8 pb-4">{m.catalogue_all_loaded()}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -712,8 +691,8 @@
|
||||
<button
|
||||
onclick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
class="fixed bottom-6 right-6 z-50 p-3 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-text) shadow-lg hover:bg-(--color-surface-3) hover:text-(--color-text) transition-colors"
|
||||
title="Back to top"
|
||||
aria-label="Scroll to top"
|
||||
title={m.catalogue_scroll_top()}
|
||||
aria-label={m.catalogue_scroll_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" />
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Disclaimer — libnovel</title>
|
||||
<title>{m.disclaimer_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto py-10 px-4">
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>DMCA — libnovel</title>
|
||||
<title>{m.dmca_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto py-10 px-4">
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { PageServerLoad } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: { error?: string } } = $props();
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
oauth_state: 'Sign-in was cancelled or expired. Please try again.',
|
||||
oauth_failed: 'Could not connect to the provider. Please try again.',
|
||||
oauth_no_email: 'Your account has no verified email address. Please add one and retry.'
|
||||
};
|
||||
const errorMessages = $derived<Record<string, string>>({
|
||||
oauth_state: m.login_error_oauth_state(),
|
||||
oauth_failed: m.login_error_oauth_failed(),
|
||||
oauth_no_email: m.login_error_oauth_no_email()
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sign in — libnovel</title>
|
||||
<title>{m.login_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="w-full max-w-sm">
|
||||
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl font-bold text-(--color-text) mb-2">Sign in to libnovel</h1>
|
||||
<p class="text-sm text-(--color-muted)">Choose a provider to continue</p>
|
||||
<h1 class="text-2xl font-bold text-(--color-text) mb-2">{m.login_heading()}</h1>
|
||||
<p class="text-sm text-(--color-muted)">{m.login_subheading()}</p>
|
||||
</div>
|
||||
|
||||
{#if data.error && errorMessages[data.error]}
|
||||
@@ -54,7 +54,7 @@
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
{m.login_continue_google()}
|
||||
</a>
|
||||
|
||||
<!-- GitHub -->
|
||||
@@ -76,12 +76,12 @@
|
||||
2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z"
|
||||
/>
|
||||
</svg>
|
||||
Continue with GitHub
|
||||
{m.login_continue_github()}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="mt-8 text-center text-xs text-(--color-muted)">
|
||||
By signing in you agree to our terms of service.
|
||||
{m.login_terms_notice()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Privacy Policy — libnovel</title>
|
||||
<title>{m.privacy_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto py-10 px-4">
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import type { Voice } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
@@ -93,10 +94,10 @@
|
||||
const themeCtx = getContext<{ currentTheme: string; setTheme: (t: string) => void } | undefined>('theme');
|
||||
let selectedTheme = $state(untrack(() => data.settings?.theme ?? themeCtx?.currentTheme ?? 'amber'));
|
||||
|
||||
const THEMES: { id: string; label: string; swatch: string }[] = [
|
||||
{ id: 'amber', label: 'Amber', swatch: '#f59e0b' },
|
||||
{ id: 'slate', label: 'Slate', swatch: '#818cf8' },
|
||||
{ id: 'rose', label: 'Rose', swatch: '#fb7185' },
|
||||
const THEMES: { id: string; label: () => string; swatch: string }[] = [
|
||||
{ id: 'amber', label: () => m.profile_theme_amber(), swatch: '#f59e0b' },
|
||||
{ id: 'slate', label: () => m.profile_theme_slate(), swatch: '#818cf8' },
|
||||
{ id: 'rose', label: () => m.profile_theme_rose(), swatch: '#fb7185' },
|
||||
];
|
||||
|
||||
let settingsSaving = $state(false);
|
||||
@@ -204,7 +205,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profile — libnovel</title>
|
||||
<title>{m.profile_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if cropFile && browser}
|
||||
@@ -227,7 +228,7 @@
|
||||
<button
|
||||
onclick={() => fileInput?.click()}
|
||||
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-(--color-border) hover:ring-(--color-brand) transition-all focus:outline-none focus:ring-(--color-brand)"
|
||||
title="Change profile picture"
|
||||
title={m.profile_change_avatar()}
|
||||
disabled={avatarUploading}
|
||||
>
|
||||
{#if avatarUrl}
|
||||
@@ -269,17 +270,17 @@
|
||||
{#if avatarError}
|
||||
<p class="text-(--color-danger) text-xs mt-1">{avatarError}</p>
|
||||
{:else}
|
||||
<p class="text-(--color-muted) text-xs mt-1">Click avatar to change photo</p>
|
||||
<p class="text-(--color-muted) text-xs mt-1">{m.profile_click_to_change()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Appearance ────────────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">Appearance</h2>
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_appearance_heading()}</h2>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-(--color-text)">Theme</p>
|
||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_theme_label()}</p>
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
{#each THEMES as t}
|
||||
<button
|
||||
@@ -292,7 +293,7 @@
|
||||
aria-pressed={selectedTheme === t.id}
|
||||
>
|
||||
<span class="w-3.5 h-3.5 rounded-full flex-shrink-0" style="background: {t.swatch};"></span>
|
||||
{t.label}
|
||||
{t.label()}
|
||||
{#if selectedTheme === t.id}
|
||||
<svg class="w-3 h-3 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"/>
|
||||
@@ -306,16 +307,16 @@
|
||||
|
||||
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-5">
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">Reading settings</h2>
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_reading_heading()}</h2>
|
||||
|
||||
<!-- Voice -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="voice-select">TTS voice</label>
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="voice-select">{m.profile_tts_voice()}</label>
|
||||
{#if !voicesLoaded}
|
||||
<div class="h-9 bg-(--color-surface-3) rounded animate-pulse"></div>
|
||||
{:else if voices.length === 0}
|
||||
<select id="voice-select" disabled class="w-full bg-(--color-surface-3) border border-(--color-border) rounded-lg px-3 py-2 text-(--color-muted) text-sm cursor-not-allowed">
|
||||
<option>No voices available</option>
|
||||
<option>{m.common_loading()}</option>
|
||||
</select>
|
||||
{:else}
|
||||
<select
|
||||
@@ -344,7 +345,7 @@
|
||||
<!-- Speed -->
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="speed-range">
|
||||
Playback speed — <span class="text-(--color-brand) font-mono">{speed.toFixed(1)}x</span>
|
||||
{m.profile_playback_speed({ speed: speed.toFixed(1) })}
|
||||
</label>
|
||||
<input
|
||||
id="speed-range"
|
||||
@@ -370,7 +371,7 @@
|
||||
style="accent-color: var(--color-brand);"
|
||||
class="w-4 h-4 rounded"
|
||||
/>
|
||||
<span class="text-sm text-(--color-text)">Auto-advance to next chapter</span>
|
||||
<span class="text-sm text-(--color-text)">{m.profile_auto_advance()}</span>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-3 pt-1">
|
||||
@@ -379,18 +380,18 @@
|
||||
disabled={settingsSaving}
|
||||
class="px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60"
|
||||
>
|
||||
{settingsSaving ? 'Saving…' : 'Save settings'}
|
||||
{settingsSaving ? m.profile_saving() : m.profile_save_settings()}
|
||||
</button>
|
||||
{#if settingsSaved}
|
||||
<span class="text-sm text-green-400">Saved!</span>
|
||||
<span class="text-sm text-green-400">{m.profile_saved()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Active sessions ──────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">Active sessions</h2>
|
||||
<p class="text-sm text-(--color-muted)">These are all devices currently signed into your account. End any session you don't recognise.</p>
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_sessions_heading()}</h2>
|
||||
<p class="text-sm text-(--color-muted)">{m.profile_session_unrecognised()}</p>
|
||||
|
||||
{#if revokeError}
|
||||
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">
|
||||
@@ -399,7 +400,7 @@
|
||||
{/if}
|
||||
|
||||
{#if sessions.length === 0}
|
||||
<p class="text-sm text-(--color-muted) italic">No session records found. Sessions are tracked from the next login.</p>
|
||||
<p class="text-sm text-(--color-muted) italic">{m.profile_no_sessions()}</p>
|
||||
{:else}
|
||||
<ul class="space-y-2">
|
||||
{#each sessions as session (session.id)}
|
||||
@@ -408,16 +409,16 @@
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium text-(--color-text) 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-(--color-brand)/20 text-(--color-brand-dim) border border-(--color-brand)/40">This session</span>
|
||||
<span class="shrink-0 text-xs font-semibold px-1.5 py-0.5 rounded bg-(--color-brand)/20 text-(--color-brand-dim) border border-(--color-brand)/40">{m.profile_session_this()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if session.ip}
|
||||
<p class="text-xs text-(--color-muted) font-mono">{session.ip}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
Signed in {formatDate(session.created_at)}
|
||||
{m.profile_session_signed_in({ date: formatDate(session.created_at) })}
|
||||
{#if session.last_seen && session.last_seen !== session.created_at}
|
||||
· Last seen {formatDate(session.last_seen)}
|
||||
{m.profile_session_last_seen({ date: formatDate(session.last_seen) })}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
@@ -429,7 +430,7 @@
|
||||
? 'bg-(--color-danger)/10 text-(--color-danger) border border-(--color-danger)/60 hover:bg-(--color-danger)/20'
|
||||
: 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border) hover:bg-(--color-surface-3)'}"
|
||||
>
|
||||
{revokingId === session.id ? '…' : session.is_current ? 'Sign out' : 'End'}
|
||||
{revokingId === session.id ? '…' : session.is_current ? m.profile_session_sign_out() : m.profile_session_end()}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
@@ -439,7 +440,7 @@
|
||||
|
||||
<!-- ── Change password ──────────────────────────────────────────────────── -->
|
||||
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6 space-y-4">
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">Change password</h2>
|
||||
<h2 class="text-lg font-semibold text-(--color-text)">{m.profile_change_password_heading()}</h2>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-lg bg-(--color-danger)/10 border border-(--color-danger) px-4 py-2.5 text-sm text-(--color-danger)">
|
||||
@@ -449,7 +450,7 @@
|
||||
|
||||
{#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.
|
||||
{m.profile_password_changed_ok()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -466,7 +467,7 @@
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="current">Current password</label>
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="current">{m.profile_current_password()}</label>
|
||||
<input
|
||||
id="current"
|
||||
name="current"
|
||||
@@ -477,7 +478,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="next">New password</label>
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="next">{m.profile_new_password()}</label>
|
||||
<input
|
||||
id="next"
|
||||
name="next"
|
||||
@@ -488,7 +489,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="confirm">Confirm new password</label>
|
||||
<label class="block text-sm font-medium text-(--color-text)" for="confirm">{m.profile_confirm_password()}</label>
|
||||
<input
|
||||
id="confirm"
|
||||
name="confirm"
|
||||
@@ -503,7 +504,7 @@
|
||||
disabled={pwSubmitting}
|
||||
class="px-4 py-2 rounded-lg bg-(--color-surface-3) text-(--color-text) font-semibold text-sm hover:bg-(--color-surface-3) transition-colors disabled:opacity-60"
|
||||
>
|
||||
{pwSubmitting ? 'Updating…' : 'Update password'}
|
||||
{pwSubmitting ? m.profile_updating() : m.profile_update_password()}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Terms of Service — libnovel</title>
|
||||
<title>{m.terms_page_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto py-10 px-4">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
@@ -50,7 +51,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.profile.username} — libnovel</title>
|
||||
<title>{m.user_page_title({ username: data.profile.username })}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- ── Header ────────────────────────────────────────────────────────────── -->
|
||||
@@ -73,17 +74,17 @@
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-xl font-bold text-(--color-text) mb-0.5">{data.profile.username}</h1>
|
||||
<p class="text-xs text-(--color-muted) mb-3">Joined {joinDate(data.profile.created)}</p>
|
||||
<p class="text-xs text-(--color-muted) mb-3">{m.user_joined({ date: joinDate(data.profile.created) })}</p>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="flex gap-5 text-sm mb-4">
|
||||
<span>
|
||||
<span class="font-semibold text-(--color-text)">{followerCount}</span>
|
||||
<span class="text-(--color-muted) ml-1">followers</span>
|
||||
<span class="text-(--color-muted) ml-1">{m.user_followers_label()}</span>
|
||||
</span>
|
||||
<span>
|
||||
<span class="font-semibold text-(--color-text)">{data.profile.followingCount}</span>
|
||||
<span class="text-(--color-muted) ml-1">following</span>
|
||||
<span class="text-(--color-muted) ml-1">{m.user_following_label()}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -100,9 +101,9 @@
|
||||
{#if subLoading}
|
||||
…
|
||||
{:else if subscribed}
|
||||
Following
|
||||
{m.user_unfollow()}
|
||||
{:else}
|
||||
Follow
|
||||
{m.user_follow()}
|
||||
{/if}
|
||||
</button>
|
||||
{:else if !data.isLoggedIn}
|
||||
@@ -110,7 +111,7 @@
|
||||
href="/login"
|
||||
class="inline-block px-4 py-1.5 rounded-lg text-sm font-medium bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) transition-colors"
|
||||
>
|
||||
Follow
|
||||
{m.user_follow()}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -119,7 +120,7 @@
|
||||
<!-- ── Currently Reading ─────────────────────────────────────────────────── -->
|
||||
{#if data.currentlyReading.length > 0}
|
||||
<section class="mb-10">
|
||||
<h2 class="text-base font-semibold text-(--color-text) mb-3">Currently Reading</h2>
|
||||
<h2 class="text-base font-semibold text-(--color-text) mb-3">{m.user_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
|
||||
@@ -161,8 +162,7 @@
|
||||
{#if data.library.length > 0}
|
||||
<section class="mb-10">
|
||||
<h2 class="text-base font-semibold text-(--color-text) mb-3">
|
||||
Library
|
||||
<span class="text-(--color-muted) font-normal text-sm ml-1">({data.library.length})</span>
|
||||
{m.user_library_count({ n: String(data.library.length) })}
|
||||
</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 }}
|
||||
@@ -220,6 +220,6 @@
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-(--color-border)" 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>
|
||||
<p class="text-sm">{m.user_no_books()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user