|
|
|
|
@@ -286,23 +286,36 @@
|
|
|
|
|
const wordCount = $derived(
|
|
|
|
|
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Audio panel: auto-open if this chapter is already loaded/playing in the store
|
|
|
|
|
// svelte-ignore state_referenced_locally
|
|
|
|
|
let audioExpanded = $state(
|
|
|
|
|
audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number
|
|
|
|
|
);
|
|
|
|
|
$effect(() => {
|
|
|
|
|
// Expand automatically when the store starts playing this chapter
|
|
|
|
|
if (audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.isPlaying) {
|
|
|
|
|
audioExpanded = true;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<svelte:head>
|
|
|
|
|
<title>{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })} — {data.book.title} — libnovel</title>
|
|
|
|
|
</svelte:head>
|
|
|
|
|
|
|
|
|
|
<!-- Reading progress bar (scroll mode) -->
|
|
|
|
|
<!-- Reading progress bar (scroll mode, fixed at top of viewport) -->
|
|
|
|
|
{#if layout.readMode === 'scroll'}
|
|
|
|
|
<div class="reading-progress" style="width: {scrollProgress * 100}%"></div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- Top nav (hidden in focus mode) -->
|
|
|
|
|
<!-- ── Top navigation (hidden in focus mode) ─────────────────────────────── -->
|
|
|
|
|
{#if !layout.focusMode}
|
|
|
|
|
<div class="flex items-center justify-between mb-6 gap-4">
|
|
|
|
|
<div class="flex items-center justify-between mb-8 gap-2">
|
|
|
|
|
<!-- Left: back to chapters list -->
|
|
|
|
|
<a
|
|
|
|
|
href="/books/{data.book.slug}/chapters"
|
|
|
|
|
class="text-(--color-muted) hover:text-(--color-text) text-sm flex items-center gap-1 transition-colors"
|
|
|
|
|
class="flex items-center gap-1 text-(--color-muted) hover:text-(--color-text) text-sm transition-colors shrink-0"
|
|
|
|
|
>
|
|
|
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
|
|
|
@@ -310,148 +323,177 @@
|
|
|
|
|
{m.reader_back_to_chapters()}
|
|
|
|
|
</a>
|
|
|
|
|
|
|
|
|
|
<div class="flex gap-2">
|
|
|
|
|
<!-- Right: prev/next chapter arrows + settings -->
|
|
|
|
|
<div class="flex items-center gap-1">
|
|
|
|
|
{#if data.prev}
|
|
|
|
|
<a
|
|
|
|
|
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-2) transition-colors"
|
|
|
|
|
title="{m.reader_chapter_n({ n: String(data.prev) })}"
|
|
|
|
|
class="p-2 rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
|
|
|
|
>
|
|
|
|
|
← {m.reader_chapter_n({ n: String(data.prev) })}
|
|
|
|
|
<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>
|
|
|
|
|
</a>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if data.next}
|
|
|
|
|
<a
|
|
|
|
|
href="/books/{data.book.slug}/chapters/{data.next}"
|
|
|
|
|
class="px-3 py-1.5 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
|
|
|
|
|
title="{m.reader_chapter_n({ n: String(data.next) })}"
|
|
|
|
|
class="p-2 rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{m.reader_chapter_n({ n: String(data.next) })} →
|
|
|
|
|
<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="M9 5l7 7-7 7" />
|
|
|
|
|
</svg>
|
|
|
|
|
</a>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if settingsCtx}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onclick={() => { settingsPanelOpen = !settingsPanelOpen; settingsTab = 'reading'; }}
|
|
|
|
|
aria-label="Reader settings"
|
|
|
|
|
class="p-2 rounded-lg transition-colors hover:bg-(--color-surface-2) {settingsPanelOpen ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
|
|
|
|
>
|
|
|
|
|
<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="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Chapter heading -->
|
|
|
|
|
<div class="mb-4">
|
|
|
|
|
<h1 class="text-xl font-bold text-(--color-text)">
|
|
|
|
|
<!-- Chapter heading + meta + language switcher -->
|
|
|
|
|
<div class="mb-6">
|
|
|
|
|
<h1 class="text-xl font-bold text-(--color-text) leading-snug">
|
|
|
|
|
{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">
|
|
|
|
|
{m.reader_words({ n: wordCount.toLocaleString() })}
|
|
|
|
|
<span class="opacity-50 mx-1">·</span>
|
|
|
|
|
~{Math.max(1, Math.round(wordCount / 200))} min read
|
|
|
|
|
</p>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- Language switcher (not shown for preview chapters or focus mode) -->
|
|
|
|
|
{#if !data.isPreview && !layout.focusMode}
|
|
|
|
|
<div class="flex items-center gap-2 mb-6 flex-wrap">
|
|
|
|
|
<span class="text-(--color-muted) text-xs">Read in:</span>
|
|
|
|
|
|
|
|
|
|
<!-- English (original) -->
|
|
|
|
|
<a
|
|
|
|
|
href={langUrl('')}
|
|
|
|
|
class="px-2 py-0.5 rounded text-xs font-medium transition-colors {currentLang() === '' ? 'bg-(--color-brand) text-(--color-surface)' : 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
|
|
|
|
|
>
|
|
|
|
|
EN
|
|
|
|
|
</a>
|
|
|
|
|
|
|
|
|
|
{#each SUPPORTED_LANGS as { code, label }}
|
|
|
|
|
{#if !data.isPro}
|
|
|
|
|
<!-- Locked for free users -->
|
|
|
|
|
<a
|
|
|
|
|
href="/profile"
|
|
|
|
|
title="Upgrade to Pro to read in {label}"
|
|
|
|
|
class="flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) opacity-60 cursor-pointer hover:opacity-80 transition-opacity"
|
|
|
|
|
>
|
|
|
|
|
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
|
|
|
|
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
|
|
|
|
|
</svg>
|
|
|
|
|
{label}
|
|
|
|
|
</a>
|
|
|
|
|
{:else if currentLang() === code && (translationStatus === 'pending' || translationStatus === 'running')}
|
|
|
|
|
<!-- Spinning indicator while translating -->
|
|
|
|
|
<span class="flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted)">
|
|
|
|
|
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
|
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
|
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
|
|
|
</svg>
|
|
|
|
|
{label}
|
|
|
|
|
</span>
|
|
|
|
|
{:else if currentLang() === code}
|
|
|
|
|
<!-- Active translated lang -->
|
|
|
|
|
<a
|
|
|
|
|
href={langUrl(code)}
|
|
|
|
|
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-brand) text-(--color-surface)"
|
|
|
|
|
>{label}</a>
|
|
|
|
|
{:else}
|
|
|
|
|
<!-- Inactive lang: click to request/navigate -->
|
|
|
|
|
<button
|
|
|
|
|
onclick={() => requestTranslation(code)}
|
|
|
|
|
disabled={translatingLang !== '' && translatingLang !== code && (translationStatus === 'pending' || translationStatus === 'running')}
|
|
|
|
|
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
|
|
|
>{label}</button>
|
|
|
|
|
<div class="flex items-center flex-wrap gap-x-3 gap-y-1.5 mt-2">
|
|
|
|
|
{#if wordCount > 0}
|
|
|
|
|
<p class="text-(--color-muted) text-xs">
|
|
|
|
|
{m.reader_words({ n: wordCount.toLocaleString() })}
|
|
|
|
|
<span class="opacity-40 mx-0.5">·</span>
|
|
|
|
|
~{Math.max(1, Math.round(wordCount / 200))} min
|
|
|
|
|
</p>
|
|
|
|
|
{/if}
|
|
|
|
|
{/each}
|
|
|
|
|
|
|
|
|
|
{#if !data.isPro}
|
|
|
|
|
<a href="/profile" class="text-xs text-(--color-brand) hover:underline ml-1">Upgrade to Pro</a>
|
|
|
|
|
{/if}
|
|
|
|
|
<!-- Language switcher (inline, compact) -->
|
|
|
|
|
{#if !data.isPreview}
|
|
|
|
|
<div class="flex items-center gap-1">
|
|
|
|
|
<a
|
|
|
|
|
href={langUrl('')}
|
|
|
|
|
class="px-2 py-0.5 rounded text-xs font-medium transition-colors
|
|
|
|
|
{currentLang() === '' ? 'bg-(--color-brand) text-(--color-surface)' : 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'}"
|
|
|
|
|
>EN</a>
|
|
|
|
|
{#each SUPPORTED_LANGS as { code, label }}
|
|
|
|
|
{#if !data.isPro}
|
|
|
|
|
<a
|
|
|
|
|
href="/profile"
|
|
|
|
|
title="Upgrade to Pro to read in {label}"
|
|
|
|
|
class="flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted)/50 cursor-pointer"
|
|
|
|
|
>
|
|
|
|
|
<svg class="w-2.5 h-2.5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
|
|
|
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
|
|
|
|
|
</svg>
|
|
|
|
|
{label}
|
|
|
|
|
</a>
|
|
|
|
|
{:else if currentLang() === code && (translationStatus === 'pending' || translationStatus === 'running')}
|
|
|
|
|
<span class="flex items-center gap-0.5 px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted)">
|
|
|
|
|
<svg class="w-2.5 h-2.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
|
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
|
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
|
|
|
</svg>
|
|
|
|
|
{label}
|
|
|
|
|
</span>
|
|
|
|
|
{:else if currentLang() === code}
|
|
|
|
|
<a href={langUrl(code)} class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-brand) text-(--color-surface)">{label}</a>
|
|
|
|
|
{:else}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onclick={() => requestTranslation(code)}
|
|
|
|
|
disabled={translatingLang !== '' && translatingLang !== code && (translationStatus === 'pending' || translationStatus === 'running')}
|
|
|
|
|
class="px-2 py-0.5 rounded text-xs font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
|
|
|
>{label}</button>
|
|
|
|
|
{/if}
|
|
|
|
|
{/each}
|
|
|
|
|
{#if !data.isPro}
|
|
|
|
|
<a href="/profile" class="text-xs text-(--color-brand) hover:underline ml-0.5">Pro</a>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- Audio player (hidden in focus mode) -->
|
|
|
|
|
<!-- ── Audio section (collapsible, hidden in focus/preview mode) ──────────── -->
|
|
|
|
|
{#if !data.isPreview && !layout.focusMode}
|
|
|
|
|
{#if !page.data.user}
|
|
|
|
|
<!-- Unauthenticated: sign-in prompt -->
|
|
|
|
|
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-border) flex items-center justify-between gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<p class="text-(--color-text) text-sm font-medium">{m.reader_signin_for_audio()}</p>
|
|
|
|
|
<p class="text-(--color-muted) text-xs mt-0.5">{m.reader_signin_audio_desc()}</p>
|
|
|
|
|
{#if !page.data.user}
|
|
|
|
|
<!-- Unauthenticated -->
|
|
|
|
|
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-border) flex items-center justify-between gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<p class="text-(--color-text) text-sm font-medium">{m.reader_signin_for_audio()}</p>
|
|
|
|
|
<p class="text-(--color-muted) text-xs mt-0.5">{m.reader_signin_audio_desc()}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<a href="/login" class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors">
|
|
|
|
|
{m.nav_sign_in()}
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
<a
|
|
|
|
|
href="/login"
|
|
|
|
|
class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{m.nav_sign_in()}
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
{:else if audioProRequired}
|
|
|
|
|
<div class="mb-6 px-4 py-3 rounded-lg bg-(--color-surface-2) border border-(--color-brand)/30 flex items-center justify-between gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<p class="text-(--color-text) text-sm font-medium">Daily audio limit reached</p>
|
|
|
|
|
<p class="text-(--color-muted) text-xs mt-0.5">Free users can listen to 3 chapters per day. Upgrade to Pro for unlimited audio.</p>
|
|
|
|
|
{:else if audioProRequired}
|
|
|
|
|
<div class="mb-6 px-4 py-2.5 rounded-lg bg-(--color-surface-2) border border-(--color-brand)/30 flex items-center justify-between gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<p class="text-(--color-text) text-sm font-medium">Daily audio limit reached</p>
|
|
|
|
|
<p class="text-(--color-muted) text-xs mt-0.5">Upgrade to Pro for unlimited audio.</p>
|
|
|
|
|
</div>
|
|
|
|
|
<a href="/profile" class="shrink-0 px-3 py-1.5 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors">
|
|
|
|
|
Upgrade
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
<a
|
|
|
|
|
href="/profile"
|
|
|
|
|
class="shrink-0 px-4 py-2 rounded bg-(--color-brand) text-(--color-surface) text-sm font-semibold hover:bg-(--color-brand-dim) transition-colors"
|
|
|
|
|
>
|
|
|
|
|
Upgrade
|
|
|
|
|
</a>
|
|
|
|
|
{:else}
|
|
|
|
|
<!-- Collapsible audio panel -->
|
|
|
|
|
<div class="mb-6">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onclick={() => { audioExpanded = !audioExpanded; }}
|
|
|
|
|
class="w-full flex items-center justify-between px-4 py-2.5 bg-(--color-surface-2) border border-(--color-border) text-sm text-(--color-text) transition-colors hover:border-(--color-brand)/40
|
|
|
|
|
{audioExpanded ? 'rounded-t-lg' : 'rounded-lg'}"
|
|
|
|
|
>
|
|
|
|
|
<span class="flex items-center gap-2">
|
|
|
|
|
<svg class="w-4 h-4 text-(--color-brand)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 18.364a8 8 0 010-12.728M8.464 15.536a5 5 0 010-7.072" />
|
|
|
|
|
</svg>
|
|
|
|
|
<span class="font-medium">Listen to this chapter</span>
|
|
|
|
|
{#if audioStore.slug === data.book.slug && audioStore.chapter === data.chapter.number && audioStore.isPlaying}
|
|
|
|
|
<span class="text-xs text-(--color-brand)">● Playing</span>
|
|
|
|
|
{/if}
|
|
|
|
|
</span>
|
|
|
|
|
<svg class="w-4 h-4 text-(--color-muted) transition-transform duration-200 {audioExpanded ? 'rotate-180' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
{#if audioExpanded}
|
|
|
|
|
<div class="border border-t-0 border-(--color-border) rounded-b-lg overflow-hidden">
|
|
|
|
|
<AudioPlayer
|
|
|
|
|
slug={data.book.slug}
|
|
|
|
|
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}
|
|
|
|
|
chapters={data.chapters}
|
|
|
|
|
voices={data.voices}
|
|
|
|
|
playerStyle={layout.playerStyle}
|
|
|
|
|
onProRequired={() => { audioProRequired = true; }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
{:else if data.isPreview}
|
|
|
|
|
<div class="mb-6 px-4 py-2 rounded bg-(--color-surface-2)/60 border border-(--color-border) text-(--color-muted) text-sm">
|
|
|
|
|
{m.reader_preview_audio_notice()}
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<AudioPlayer
|
|
|
|
|
slug={data.book.slug}
|
|
|
|
|
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}
|
|
|
|
|
chapters={data.chapters}
|
|
|
|
|
voices={data.voices}
|
|
|
|
|
playerStyle={layout.playerStyle}
|
|
|
|
|
onProRequired={() => { audioProRequired = true; }}
|
|
|
|
|
/>
|
|
|
|
|
{/if}
|
|
|
|
|
{:else}
|
|
|
|
|
<div class="mb-6 px-4 py-3 rounded bg-(--color-surface-2)/60 border border-(--color-border) text-(--color-muted) text-sm">
|
|
|
|
|
{m.reader_preview_audio_notice()}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- Chapter content -->
|
|
|
|
|
<!-- ── Chapter content ───────────────────────────────────────────────────── -->
|
|
|
|
|
{#if fetchingContent}
|
|
|
|
|
<div class="flex flex-col items-center gap-3 py-16 text-(--color-muted) text-sm">
|
|
|
|
|
<svg class="w-6 h-6 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
|
|
|
@@ -465,7 +507,7 @@
|
|
|
|
|
<p>{fetchError || m.reader_audio_error()}</p>
|
|
|
|
|
</div>
|
|
|
|
|
{:else if layout.readMode === 'paginated'}
|
|
|
|
|
<!-- ── Paginated reader ─────────────────────────────────────────────── -->
|
|
|
|
|
<!-- ── Paginated reader ───────────────────────────────────────────── -->
|
|
|
|
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
|
|
|
<div
|
|
|
|
|
role="none"
|
|
|
|
|
@@ -496,11 +538,9 @@
|
|
|
|
|
</svg>
|
|
|
|
|
Prev
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<span class="text-sm text-(--color-muted) tabular-nums">
|
|
|
|
|
{pageIndex + 1} <span class="opacity-40">/</span> {totalPages}
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onclick={() => { if (pageIndex < totalPages - 1) pageIndex++; }}
|
|
|
|
|
@@ -513,50 +553,59 @@
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Tap hint -->
|
|
|
|
|
<p class="text-center text-xs text-(--color-muted)/40 mt-2">Tap left/right · Arrow keys · Space</p>
|
|
|
|
|
{:else}
|
|
|
|
|
<!-- ── Scroll reader ────────────────────────────────────────────────── -->
|
|
|
|
|
<!-- ── Scroll reader ──────────────────────────────────────────────── -->
|
|
|
|
|
<div class="prose-chapter mt-8 {layout.paraStyle === 'indented' ? 'para-indented' : ''}">
|
|
|
|
|
{@html html}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- Bottom nav + comments (hidden in focus mode) -->
|
|
|
|
|
<!-- ── Bottom navigation + comments (hidden in focus mode) ───────────────── -->
|
|
|
|
|
{#if !layout.focusMode}
|
|
|
|
|
<div class="flex justify-between mt-12 pt-6 border-t border-(--color-border) gap-4">
|
|
|
|
|
{#if data.prev}
|
|
|
|
|
<a
|
|
|
|
|
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-2) transition-colors"
|
|
|
|
|
>
|
|
|
|
|
← {m.reader_prev_chapter()}
|
|
|
|
|
</a>
|
|
|
|
|
{:else}
|
|
|
|
|
<span></span>
|
|
|
|
|
{/if}
|
|
|
|
|
{#if data.next}
|
|
|
|
|
<a
|
|
|
|
|
href="/books/{data.book.slug}/chapters/{data.next}"
|
|
|
|
|
class="px-4 py-2 rounded bg-(--color-surface-3) text-(--color-text) text-sm hover:bg-(--color-surface-2) transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{m.reader_next_chapter()} →
|
|
|
|
|
</a>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="mt-14 pt-6 border-t border-(--color-border)">
|
|
|
|
|
<!-- Next chapter: prominent full-width CTA -->
|
|
|
|
|
{#if data.next}
|
|
|
|
|
<a
|
|
|
|
|
href="/books/{data.book.slug}/chapters/{data.next}"
|
|
|
|
|
class="group flex items-center justify-between px-5 py-4 rounded-xl bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<div>
|
|
|
|
|
<p class="text-xs text-(--color-muted) mb-0.5">{m.reader_next_chapter()}</p>
|
|
|
|
|
<p class="text-(--color-text) font-semibold group-hover:text-(--color-brand) transition-colors">
|
|
|
|
|
{m.reader_chapter_n({ n: String(data.next) })}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<svg class="w-5 h-5 text-(--color-muted) group-hover:text-(--color-brand) group-hover:translate-x-0.5 transition-all" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
|
|
|
</svg>
|
|
|
|
|
</a>
|
|
|
|
|
{/if}
|
|
|
|
|
<!-- Previous chapter: small secondary link -->
|
|
|
|
|
{#if data.prev}
|
|
|
|
|
<a
|
|
|
|
|
href="/books/{data.book.slug}/chapters/{data.prev}"
|
|
|
|
|
class="mt-3 inline-flex items-center gap-1 text-sm text-(--color-muted) hover:text-(--color-text) transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
|
|
|
</svg>
|
|
|
|
|
{m.reader_prev_chapter()}
|
|
|
|
|
</a>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="mt-12">
|
|
|
|
|
<CommentsSection
|
|
|
|
|
slug={data.book.slug}
|
|
|
|
|
chapter={data.chapter.number}
|
|
|
|
|
isLoggedIn={!!page.data.user}
|
|
|
|
|
currentUserId={page.data.user?.id ?? ''}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="mt-12">
|
|
|
|
|
<CommentsSection
|
|
|
|
|
slug={data.book.slug}
|
|
|
|
|
chapter={data.chapter.number}
|
|
|
|
|
isLoggedIn={!!page.data.user}
|
|
|
|
|
currentUserId={page.data.user?.id ?? ''}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- Focus mode floating nav (shown only in focus mode) -->
|
|
|
|
|
<!-- ── Focus mode floating nav ───────────────────────────────────────────── -->
|
|
|
|
|
{#if layout.focusMode}
|
|
|
|
|
<div class="fixed bottom-[4.5rem] left-1/2 -translate-x-1/2 z-50 flex items-center gap-2">
|
|
|
|
|
{#if data.prev}
|
|
|
|
|
@@ -595,22 +644,8 @@
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- ── Reader settings bottom sheet ──────────────────────────────────────── -->
|
|
|
|
|
<!-- ── Reader settings bottom sheet ─────────────────────────────────────── -->
|
|
|
|
|
{#if settingsCtx}
|
|
|
|
|
|
|
|
|
|
<!-- Gear button — sits just above the mini-player (bottom-[4.5rem]) -->
|
|
|
|
|
<button
|
|
|
|
|
onclick={() => { settingsPanelOpen = !settingsPanelOpen; settingsTab = 'reading'; }}
|
|
|
|
|
aria-label="Reader settings"
|
|
|
|
|
class="fixed bottom-[4.5rem] right-4 z-50 w-11 h-11 rounded-full bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex items-center justify-center shadow-lg"
|
|
|
|
|
>
|
|
|
|
|
<svg class="w-5 h-5 {settingsPanelOpen ? 'text-(--color-brand)' : ''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<!-- Bottom sheet -->
|
|
|
|
|
{#if settingsPanelOpen}
|
|
|
|
|
<!-- Backdrop -->
|
|
|
|
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
|
|
|
@@ -648,12 +683,11 @@
|
|
|
|
|
|
|
|
|
|
{#if settingsTab === 'reading'}
|
|
|
|
|
|
|
|
|
|
<!-- ── Typography group ──────────────────────────────────────── -->
|
|
|
|
|
<!-- ── Typography ──────────────────────────────────────────────── -->
|
|
|
|
|
<div class="mb-1">
|
|
|
|
|
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Typography</p>
|
|
|
|
|
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
|
|
|
|
|
|
|
|
|
|
<!-- Font -->
|
|
|
|
|
<div class="flex items-center gap-3 px-3 py-2.5">
|
|
|
|
|
<span class="text-xs text-(--color-muted) w-10 shrink-0">Font</span>
|
|
|
|
|
<div class="flex gap-1.5 flex-1">
|
|
|
|
|
@@ -670,7 +704,6 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Size -->
|
|
|
|
|
<div class="flex items-center gap-3 px-3 py-2.5">
|
|
|
|
|
<span class="text-xs text-(--color-muted) w-10 shrink-0">Size</span>
|
|
|
|
|
<div class="flex gap-1.5 flex-1">
|
|
|
|
|
@@ -690,12 +723,11 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- ── Layout group ──────────────────────────────────────────── -->
|
|
|
|
|
<!-- ── Layout ──────────────────────────────────────────────────── -->
|
|
|
|
|
<div class="mt-4 mb-1">
|
|
|
|
|
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Layout</p>
|
|
|
|
|
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
|
|
|
|
|
|
|
|
|
|
<!-- Read mode -->
|
|
|
|
|
<div class="flex items-center gap-3 px-3 py-2.5">
|
|
|
|
|
<span class="text-xs text-(--color-muted) w-16 shrink-0">Mode</span>
|
|
|
|
|
<div class="flex gap-1.5 flex-1">
|
|
|
|
|
@@ -713,7 +745,6 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Line spacing -->
|
|
|
|
|
<div class="flex items-center gap-3 px-3 py-2.5">
|
|
|
|
|
<span class="text-xs text-(--color-muted) w-16 shrink-0">Spacing</span>
|
|
|
|
|
<div class="flex gap-1.5 flex-1">
|
|
|
|
|
@@ -731,7 +762,6 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Width -->
|
|
|
|
|
<div class="flex items-center gap-3 px-3 py-2.5">
|
|
|
|
|
<span class="text-xs text-(--color-muted) w-16 shrink-0">Width</span>
|
|
|
|
|
<div class="flex gap-1.5 flex-1">
|
|
|
|
|
@@ -749,7 +779,6 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Paragraphs -->
|
|
|
|
|
<div class="flex items-center gap-3 px-3 py-2.5">
|
|
|
|
|
<span class="text-xs text-(--color-muted) w-16 shrink-0">Paragraphs</span>
|
|
|
|
|
<div class="flex gap-1.5 flex-1">
|
|
|
|
|
@@ -767,7 +796,6 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Focus mode -->
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onclick={() => setLayout('focusMode', !layout.focusMode)}
|
|
|
|
|
@@ -784,12 +812,11 @@
|
|
|
|
|
|
|
|
|
|
{:else}
|
|
|
|
|
|
|
|
|
|
<!-- ── Listening tab ──────────────────────────────────────────── -->
|
|
|
|
|
<!-- ── Listening tab ───────────────────────────────────────────── -->
|
|
|
|
|
<div class="mb-1">
|
|
|
|
|
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Player</p>
|
|
|
|
|
<div class="bg-(--color-surface-3) rounded-xl overflow-hidden divide-y divide-(--color-border)">
|
|
|
|
|
|
|
|
|
|
<!-- Player style -->
|
|
|
|
|
<div class="flex items-center gap-3 px-3 py-2.5">
|
|
|
|
|
<span class="text-xs text-(--color-muted) w-14 shrink-0">Style</span>
|
|
|
|
|
<div class="flex gap-1.5 flex-1">
|
|
|
|
|
|