Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c5c25e5dd | ||
|
|
5177320418 | ||
|
|
836c9855af | ||
|
|
5c2c9b1b67 |
@@ -189,6 +189,15 @@ html {
|
||||
display: none; /* Chrome / Safari / WebKit */
|
||||
}
|
||||
|
||||
/* ── Hero carousel fade ─────────────────────────────────────────────── */
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
/* ── Navigation progress bar ───────────────────────────────────────── */
|
||||
@keyframes progress-bar {
|
||||
0% { width: 0%; opacity: 1; }
|
||||
|
||||
@@ -34,6 +34,14 @@
|
||||
|
||||
// ── Chapter search ────────────────────────────────────────────────────────
|
||||
let chapterSearch = $state('');
|
||||
|
||||
// Scroll the current chapter into view instantly (no animation) when the
|
||||
// chapter modal opens. Applied to every chapter button; only scrolls when
|
||||
// the chapter number matches the currently playing one. Runs once on mount
|
||||
// before the browser paints so no scroll animation is ever visible.
|
||||
function scrollIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
}
|
||||
const filteredChapters = $derived(
|
||||
chapterSearch.trim() === ''
|
||||
? audioStore.chapters
|
||||
@@ -386,11 +394,12 @@
|
||||
</div>
|
||||
<!-- Chapter list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(ch.number)}
|
||||
class={cn(
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(ch.number)}
|
||||
use:scrollIfActive={ch.number === audioStore.chapter}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
|
||||
ch.number === audioStore.chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import '../app.css';
|
||||
import { page, navigating } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { setContext } from 'svelte';
|
||||
import { setContext, untrack } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
@@ -156,15 +156,23 @@
|
||||
});
|
||||
|
||||
// Handle toggle requests from AudioPlayer controller.
|
||||
// IMPORTANT: isPlaying must be read inside untrack() so the effect only
|
||||
// re-runs when toggleRequest increments, not every time isPlaying changes.
|
||||
// Without untrack the effect subscribes to both toggleRequest AND isPlaying,
|
||||
// causing an infinite play/pause loop: play() fires onplay → isPlaying=true
|
||||
// → effect re-runs → sees isPlaying=true → calls pause() → onpause fires
|
||||
// → isPlaying=false → effect re-runs → calls play() → …
|
||||
$effect(() => {
|
||||
// Read toggleRequest to subscribe; ignore value 0 (initial).
|
||||
const _req = audioStore.toggleRequest;
|
||||
if (!audioEl || _req === 0) return;
|
||||
if (audioStore.isPlaying) {
|
||||
audioEl.pause();
|
||||
} else {
|
||||
audioEl.play().catch(() => {});
|
||||
}
|
||||
untrack(() => {
|
||||
if (audioStore.isPlaying) {
|
||||
audioEl!.pause();
|
||||
} else {
|
||||
audioEl!.play().catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle seek requests from AudioPlayer controller.
|
||||
|
||||
@@ -72,9 +72,44 @@
|
||||
'Reincarnation', 'Sci-Fi', 'Horror', 'Slice of Life', 'Adventure',
|
||||
];
|
||||
|
||||
const heroBook = $derived(data.continueInProgress[0] ?? null);
|
||||
const shelfBooks = $derived(data.continueInProgress.slice(1));
|
||||
const streak = $derived(data.stats.streak ?? 0);
|
||||
// ── Hero carousel ────────────────────────────────────────────────────────
|
||||
const heroBooks = $derived(data.continueInProgress);
|
||||
let heroIndex = $state(0);
|
||||
const heroBook = $derived(heroBooks[heroIndex] ?? null);
|
||||
// Shelf shows remaining books not in the hero
|
||||
const shelfBooks = $derived(
|
||||
heroBooks.length > 1 ? heroBooks.filter((_, i) => i !== heroIndex) : []
|
||||
);
|
||||
const streak = $derived(data.stats.streak ?? 0);
|
||||
|
||||
function heroPrev() {
|
||||
heroIndex = (heroIndex - 1 + heroBooks.length) % heroBooks.length;
|
||||
resetAutoAdvance();
|
||||
}
|
||||
function heroNext() {
|
||||
heroIndex = (heroIndex + 1) % heroBooks.length;
|
||||
resetAutoAdvance();
|
||||
}
|
||||
function heroDot(i: number) {
|
||||
heroIndex = i;
|
||||
resetAutoAdvance();
|
||||
}
|
||||
|
||||
let autoAdvanceTimer = $state<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
function resetAutoAdvance() {
|
||||
if (autoAdvanceTimer) clearInterval(autoAdvanceTimer);
|
||||
if (heroBooks.length > 1) {
|
||||
autoAdvanceTimer = setInterval(() => {
|
||||
heroIndex = (heroIndex + 1) % heroBooks.length;
|
||||
}, 6000);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
resetAutoAdvance();
|
||||
return () => { if (autoAdvanceTimer) clearInterval(autoAdvanceTimer); };
|
||||
});
|
||||
|
||||
function playChapter(slug: string, chapter: number) {
|
||||
audioStore.autoStartChapter = chapter;
|
||||
@@ -86,63 +121,108 @@
|
||||
<title>{m.home_title()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- ── Hero resume card ──────────────────────────────────────────────────────── -->
|
||||
<!-- ── Hero carousel ──────────────────────────────────────────────────────────── -->
|
||||
{#if heroBook}
|
||||
<section class="mb-6">
|
||||
<div class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all">
|
||||
<!-- Cover -->
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden block">
|
||||
{#if heroBook.book.cover}
|
||||
<img src={heroBook.book.cover} alt={heroBook.book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" loading="eager" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
<div class="relative">
|
||||
<!-- Card -->
|
||||
<div class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all">
|
||||
<!-- Cover -->
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden block">
|
||||
{#if heroBook.book.cover}
|
||||
{#key heroIndex}
|
||||
<img src={heroBook.book.cover} alt={heroBook.book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 animate-fade-in" loading="eager" />
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
|
||||
{#if heroBook.book.author}
|
||||
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
|
||||
{/if}
|
||||
{#if heroBook.book.summary}
|
||||
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.summary}</p>
|
||||
{/if}
|
||||
<!-- Info -->
|
||||
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0 flex-1">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
|
||||
{#if heroBook.book.author}
|
||||
<p class="text-sm text-(--color-muted)">{heroBook.book.author}</p>
|
||||
{/if}
|
||||
{#if heroBook.book.summary}
|
||||
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-2 max-w-prose">{heroBook.book.summary}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-4 flex-wrap">
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(heroBook!.book.slug, heroBook!.chapter)}
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 font-semibold text-sm transition-colors"
|
||||
title="Listen to narration"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 9a3 3 0 114 2.83V17m0 0a2 2 0 11-4 0m4 0H9m9-8a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
Listen
|
||||
</button>
|
||||
{#if heroBook.book.total_chapters > 0 && heroBook.chapter < heroBook.book.total_chapters}
|
||||
{@const ahead = heroBook.book.total_chapters - heroBook.chapter}
|
||||
<span class="text-xs text-(--color-muted) hidden sm:inline">{ahead} chapters ahead</span>
|
||||
{/if}
|
||||
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-4 flex-wrap">
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
|
||||
</a>
|
||||
|
||||
<!-- Prev / Next arrow buttons (only when multiple books) -->
|
||||
{#if heroBooks.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(heroBook!.book.slug, heroBook!.chapter)}
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 font-semibold text-sm transition-colors"
|
||||
title="Listen to narration"
|
||||
onclick={heroPrev}
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-(--color-surface)/80 border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex items-center justify-center backdrop-blur-sm z-10"
|
||||
aria-label="Previous book"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 9a3 3 0 114 2.83V17m0 0a2 2 0 11-4 0m4 0H9m9-8a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Listen
|
||||
</button>
|
||||
{#if heroBook.book.total_chapters > 0 && heroBook.chapter < heroBook.book.total_chapters}
|
||||
{@const ahead = heroBook.book.total_chapters - heroBook.chapter}
|
||||
<span class="text-xs text-(--color-muted) hidden sm:inline">{ahead} chapters ahead</span>
|
||||
{/if}
|
||||
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={heroNext}
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-(--color-surface)/80 border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex items-center justify-center backdrop-blur-sm z-10"
|
||||
aria-label="Next book"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Dot indicators -->
|
||||
{#if heroBooks.length > 1}
|
||||
<div class="flex items-center justify-center gap-1.5 mt-2.5">
|
||||
{#each heroBooks as _, i}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => heroDot(i)}
|
||||
aria-label="Go to book {i + 1}"
|
||||
class="rounded-full transition-all duration-300 {i === heroIndex
|
||||
? 'w-4 h-1.5 bg-(--color-brand)'
|
||||
: 'w-1.5 h-1.5 bg-(--color-border) hover:bg-(--color-muted)'}"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
type ReadWidth = 'narrow' | 'normal' | 'wide';
|
||||
type ParaStyle = 'spaced' | 'indented';
|
||||
type PlayerStyle = 'standard' | 'compact';
|
||||
/** Controls how many lines fit on a page by adjusting the container height offset. */
|
||||
type PageLines = 'less' | 'normal' | 'more';
|
||||
|
||||
interface LayoutPrefs {
|
||||
readMode: ReadMode;
|
||||
@@ -61,12 +63,19 @@
|
||||
paraStyle: ParaStyle;
|
||||
focusMode: boolean;
|
||||
playerStyle: PlayerStyle;
|
||||
pageLines: PageLines;
|
||||
}
|
||||
|
||||
const LAYOUT_KEY = 'reader_layout_v1';
|
||||
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
|
||||
const READ_WIDTHS: Record<ReadWidth, string> = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' };
|
||||
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard' };
|
||||
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
|
||||
const READ_WIDTHS: Record<ReadWidth, string> = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' };
|
||||
/**
|
||||
* Extra rem subtracted from (or added to) the paginated container height.
|
||||
* Normal (0rem) keeps the existing calc(); Less (-4rem) makes the container
|
||||
* shorter so fewer lines fit per page; More (+4rem) grows it for more lines.
|
||||
*/
|
||||
const PAGE_LINES_OFFSET: Record<PageLines, string> = { less: '4rem', normal: '0rem', more: '-4rem' };
|
||||
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard', pageLines: 'normal' };
|
||||
|
||||
function loadLayout(): LayoutPrefs {
|
||||
if (!browser) return DEFAULT_LAYOUT;
|
||||
@@ -114,8 +123,10 @@
|
||||
|
||||
$effect(() => {
|
||||
if (layout.readMode !== 'paginated') { pageIndex = 0; totalPages = 1; return; }
|
||||
// Re-run when html changes or container is bound
|
||||
// Re-run when html, container refs, or mini-player visibility changes
|
||||
// (mini-player adds pb-24 which reduces the available viewport height)
|
||||
void html; void paginatedContainerEl; void paginatedContentEl;
|
||||
void audioStore.active; void audioExpanded;
|
||||
requestAnimationFrame(() => {
|
||||
if (!paginatedContainerEl || !paginatedContentEl) return;
|
||||
const h = paginatedContainerEl.clientHeight;
|
||||
@@ -542,7 +553,9 @@
|
||||
role="none"
|
||||
bind:this={paginatedContainerEl}
|
||||
class="paginated-container mt-8"
|
||||
style="height: {layout.focusMode ? 'calc(100svh - 8rem)' : 'calc(100svh - 26rem)'};"
|
||||
style="height: {layout.focusMode
|
||||
? 'calc(100svh - 8rem)'
|
||||
: `calc(100svh - 26rem - ${PAGE_LINES_OFFSET[layout.pageLines]})`};"
|
||||
onclick={handlePaginatedClick}
|
||||
>
|
||||
<div
|
||||
@@ -783,6 +796,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if layout.readMode === 'paginated'}
|
||||
<div class="flex items-center gap-3 px-3 py-2.5">
|
||||
<span class="text-xs text-(--color-muted) w-16 shrink-0">Lines</span>
|
||||
<div class="flex gap-1.5 flex-1">
|
||||
{#each ([['less', 'Few'], ['normal', 'Normal'], ['more', 'Many']] as const) as [v, lbl]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLayout('pageLines', v)}
|
||||
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||
{layout.pageLines === v
|
||||
? '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={layout.pageLines === v}
|
||||
>{lbl}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user