@@ -22,14 +22,6 @@
let settingsPanelOpen = $state ( false );
let settingsTab = $state < 'reading' | 'listening' > ( 'reading' );
const READER_THEMES = [
{ id : 'amber' , label : 'Amber' , swatch : '#f59e0b' },
{ id : 'slate' , label : 'Slate' , swatch : '#818cf8' },
{ id : 'rose' , label : 'Rose' , swatch : '#fb7185' },
{ id : 'light' , label : 'Light' , swatch : '#d97706' , light : true },
{ id : 'light-slate' , label : 'L·Slate' , swatch : '#4f46e5' , light : true },
{ id : 'light-rose' , label : 'L·Rose' , swatch : '#e11d48' , light : true },
] as const ;
const READER_FONTS = [
{ id : 'system' , label : 'System' },
{ id : 'serif' , label : 'Serif' },
@@ -43,14 +35,9 @@
] as const ;
// Mirror context values into local reactive state so the panel shows current values
let panelTheme = $state ( settingsCtx ? . current ?? 'amber' );
let panelFont = $state ( settingsCtx ? . fontFamily ?? 'system' );
let panelSize = $state ( settingsCtx ? . fontSize ?? 1.0 );
function applyTheme ( id : string ) {
panelTheme = id ;
if ( settingsCtx ) settingsCtx . current = id ;
}
function applyFont ( id : string ) {
panelFont = id ;
if ( settingsCtx ) settingsCtx . fontFamily = id ;
@@ -97,34 +84,6 @@
if ( browser ) localStorage . setItem ( LAYOUT_KEY , JSON . stringify ( layout ));
}
// ── Listening settings helpers ───────────────────────────────────────────────
const SETTINGS_SLEEP_OPTIONS = [ 15 , 30 , 45 , 60 ];
const sleepSettingsLabel = $derived (
audioStore . sleepAfterChapter
? 'End Ch.'
: audioStore . sleepUntil > Date . now ()
? ` ${ Math . ceil (( audioStore . sleepUntil - Date . now ()) / 60000 ) } m`
: 'Off'
);
function toggleSleepFromSettings() {
if ( ! audioStore . sleepUntil && ! audioStore . sleepAfterChapter ) {
audioStore . sleepAfterChapter = true ;
} else if ( audioStore . sleepAfterChapter ) {
audioStore . sleepAfterChapter = false ;
audioStore . sleepUntil = Date . now () + SETTINGS_SLEEP_OPTIONS [ 0 ] * 60 * 1000 ;
} else {
const remaining = audioStore . sleepUntil - Date . now ();
const currentMin = Math . round ( remaining / 60000 );
const idx = SETTINGS_SLEEP_OPTIONS . findIndex (( m ) => m >= currentMin );
if ( idx === - 1 || idx === SETTINGS_SLEEP_OPTIONS . length - 1 ) {
audioStore . sleepUntil = 0 ;
} else {
audioStore . sleepUntil = Date . now () + SETTINGS_SLEEP_OPTIONS [ idx + 1 ] * 60 * 1000 ;
}
}
}
// Apply reading CSS vars whenever layout changes
$effect (() => {
if ( ! browser ) return ;
@@ -327,22 +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 -->
< div class = "flex items-center justify-between mb-6 gap-4" >
<!-- ── Top navigation (hidden in focus mode) ─────────────────────────────── -->
{ #if ! layout . focusMode }
< 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" />
@@ -350,147 +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 >
<!-- 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" >
@@ -504,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"
@@ -535,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 ++ ; }}
@@ -552,65 +553,99 @@
</ 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 paginated focus mode) -->
{ #if ! ( layout . focusMode && layout . readMode === 'paginated' ) }
< div class = "flex justify-between mt-12 pt-6 border-t border-(--color-border) gap-4 " >
<!-- ── Bottom navigation + comments (hidden in focus mode) ───────────────── -->
{ #if ! layout . focusMode }
< 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 >
{ /if }
<!-- ── 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 }
< 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"
class = "flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border -(--color-border ) text-(--color-muted) hover:text -(--color-text) text-xs transition-colors shadow-md "
>
← { m . reader_prev_chapter ()}
< 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_chapter_n ({ n : String ( data . prev ) })}
</ a >
{ : else }
< span ></ span >
{ /if }
< button
type = "button"
onclick = {() => setLayout ( 'focusMode' , false )}
class="flex items-center gap-1 px-3 py-1 . 5 rounded-full bg- ( --color-surface-2 )/ 90 backdrop-blur border border- ( --color-border ) text- ( --color-muted ) hover:text- ( --color-brand ) text-xs transition-colors shadow-md "
aria-label = "Exit focus mode"
>
< svg class = "w-3.5 h-3.5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke-linecap = "round" stroke-linejoin = "round" stroke-width = "2" d = "M6 18L18 6M6 6l12 12" />
</ svg >
Exit focus
</ button >
{ #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"
class = "flex items-center gap-1 px-3 py-1.5 rounded-full bg-(--color-surface-2)/90 backdrop-blur border border -(--color-border ) text-(--color-muted) hover:text -(--color-text) text-xs transition-colors shadow-md "
>
{ m . reader_next_ chapter ()} →
{ m . reader_chapter_n ({ n : String ( data . next ) })}
< 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 = "M9 5l7 7-7 7" />
</ svg >
</ 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 >
{ /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,33 +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)" >
<!-- Theme -->
< div class = "flex items-center gap-3 px-3 py-2.5" >
< span class = "text-xs text-(--color-muted) w-10 shrink-0" > Theme</ span >
< div class = "flex flex-wrap gap-1.5 flex-1" >
{ #each READER_THEMES as t }
< button
onclick = {() => applyTheme ( t . id )}
title= { t . label }
class = "flex items-center gap-1 px-2 py-1 rounded-lg border text-[11px] font-medium transition-colors
{ panelTheme === t . id
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)' } "
aria-pressed = { panelTheme === t . id }
>
< span class = "w-2 h-2 rounded-full shrink-0 { 'light' in t && t . light ? 'ring-1 ring-(--color-border)' : '' } " style = "background: { t . swatch } ;" ></ span >
{ t . label }
</ button >
{ /each }
</ div >
</ div >
<!-- 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" >
@@ -691,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" >
@@ -711,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" >
@@ -734,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" >
@@ -752,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" >
@@ -770,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" >
@@ -788,7 +796,6 @@
</ div >
</ div >
<!-- Focus mode -->
< button
type = "button"
onclick = {() => setLayout ( 'focusMode' , ! layout . focusMode )}
@@ -805,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" >
@@ -828,51 +834,6 @@
</ div >
</ div >
{ #if page . data . user }
<!-- Speed -->
< div class = "flex items-center gap-3 px-3 py-2.5" >
< span class = "text-xs text-(--color-muted) w-14 shrink-0" > Speed</ span >
< div class = "flex gap-1 flex-1" >
{ #each [ 0.75 , 1 , 1.25 , 1.5 , 2 ] as s }
< button
type = "button"
onclick = {() => { audioStore . speed = s ; }}
class="flex-1 py-1 . 5 rounded-lg border text-xs font-medium transition-colors
{ audioStore . speed === s
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)' } "
aria-pressed = { audioStore . speed === s }
> { s } × </button>
{ /each }
</ div >
</ div >
<!-- Auto-next -->
< button
type = "button"
onclick = {() => { audioStore . autoNext = ! audioStore . autoNext ; }}
class="w-full flex items-center justify-between px-3 py-2 . 5 text-xs font-medium transition-colors
{ audioStore . autoNext ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)' } "
aria-pressed = { audioStore . autoNext }
>
< span > Auto-next chapter</ span >
< span class = "text-(--color-muted) text-[11px]" > { audioStore . autoNext ? 'On' : 'Off' } </ span >
</ button >
<!-- Sleep timer -->
< button
type = "button"
onclick = { toggleSleepFromSettings }
class="w-full flex items-center justify-between px-3 py-2 . 5 text-xs font-medium transition-colors
{ audioStore . sleepUntil || audioStore . sleepAfterChapter ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)' } "
>
< span > Sleep timer</ span >
< span class = "text-(--color-muted) text-[11px]" > { sleepSettingsLabel } </ span >
</ button >
{ /if }
</ div >
</ div >