feat: add sticky sidebar to chapter reader with ToC, progress, book info, and chapter nav
This commit is contained in:
@@ -68,9 +68,10 @@
|
|||||||
focusMode: boolean;
|
focusMode: boolean;
|
||||||
playerStyle: PlayerStyle;
|
playerStyle: PlayerStyle;
|
||||||
pageLines: PageLines;
|
pageLines: PageLines;
|
||||||
|
showSidebar: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LAYOUT_KEY = 'reader_layout_v2';
|
const LAYOUT_KEY = 'reader_layout_v3';
|
||||||
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
|
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 READ_WIDTHS: Record<ReadWidth, string> = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' };
|
||||||
/**
|
/**
|
||||||
@@ -79,7 +80,7 @@
|
|||||||
* shorter so fewer lines fit per page; More (+4rem) grows it for more lines.
|
* 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 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' };
|
const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard', pageLines: 'normal', showSidebar: true };
|
||||||
|
|
||||||
function loadLayout(): LayoutPrefs {
|
function loadLayout(): LayoutPrefs {
|
||||||
if (!browser) return DEFAULT_LAYOUT;
|
if (!browser) return DEFAULT_LAYOUT;
|
||||||
@@ -466,6 +467,12 @@
|
|||||||
<div class="reading-progress" style="width: {scrollProgress * 100}%"></div>
|
<div class="reading-progress" style="width: {scrollProgress * 100}%"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- ── Two-column grid wrapper (sidebar activates at xl when enabled) ──────── -->
|
||||||
|
<div class="{layout.showSidebar && !layout.focusMode ? 'xl:grid xl:grid-cols-[1fr_18rem] xl:gap-10 xl:items-start' : ''}">
|
||||||
|
|
||||||
|
<!-- ── Main reading column ────────────────────────────────────────────────── -->
|
||||||
|
<div>
|
||||||
|
|
||||||
<!-- ── Top navigation (hidden in focus mode) ─────────────────────────────── -->
|
<!-- ── Top navigation (hidden in focus mode) ─────────────────────────────── -->
|
||||||
{#if !layout.focusMode}
|
{#if !layout.focusMode}
|
||||||
<div class="flex items-center justify-between mb-8 gap-2">
|
<div class="flex items-center justify-between mb-8 gap-2">
|
||||||
@@ -864,6 +871,169 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
</div><!-- end main column -->
|
||||||
|
|
||||||
|
<!-- ── Sidebar (xl+, hidden in focus mode, toggled via settings) ─────────── -->
|
||||||
|
{#if layout.showSidebar && !layout.focusMode}
|
||||||
|
<aside class="hidden xl:block">
|
||||||
|
<div class="sticky top-24 flex flex-col gap-4">
|
||||||
|
|
||||||
|
<!-- Card 1: Book cover + info -->
|
||||||
|
<div class="rounded-xl bg-(--color-surface-2) border border-(--color-border) overflow-hidden">
|
||||||
|
{#if data.book.cover}
|
||||||
|
<a href="/books/{data.book.slug}" tabindex="-1" aria-hidden="true">
|
||||||
|
<img
|
||||||
|
src={data.book.cover}
|
||||||
|
alt={data.book.title}
|
||||||
|
class="w-full aspect-[2/3] object-cover"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<div class="px-3 py-3 flex flex-col gap-2">
|
||||||
|
<a
|
||||||
|
href="/books/{data.book.slug}"
|
||||||
|
class="text-sm font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors leading-snug line-clamp-2"
|
||||||
|
>
|
||||||
|
{data.book.title}
|
||||||
|
</a>
|
||||||
|
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||||
|
<span class="tabular-nums">Ch. {data.chapter.number}</span>
|
||||||
|
{#if data.chapters.length > 0}
|
||||||
|
<span class="opacity-40">·</span>
|
||||||
|
<span class="tabular-nums">{data.chapters.length} chapters</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if wordCount > 0}
|
||||||
|
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||||
|
<span class="tabular-nums">{wordCount.toLocaleString()} words</span>
|
||||||
|
<span class="opacity-40">·</span>
|
||||||
|
<span>~{Math.max(1, Math.round(wordCount / 200))} min</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card 2: Reading progress -->
|
||||||
|
{#if data.chapters.length > 1}
|
||||||
|
{@const progressPct = Math.round((data.chapter.number / data.chapters.length) * 100)}
|
||||||
|
<div class="rounded-xl bg-(--color-surface-2) border border-(--color-border) px-4 py-3">
|
||||||
|
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Progress</p>
|
||||||
|
<div class="flex items-center justify-between text-xs text-(--color-muted) mb-1.5">
|
||||||
|
<span>Chapter {data.chapter.number} of {data.chapters.length}</span>
|
||||||
|
<span class="tabular-nums font-medium text-(--color-brand)">{progressPct}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1.5 rounded-full bg-(--color-surface-3) overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-(--color-brand) transition-all"
|
||||||
|
style="width: {progressPct}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{#if layout.readMode === 'scroll' && scrollProgress > 0}
|
||||||
|
<div class="mt-2 flex items-center gap-2 text-xs text-(--color-muted)">
|
||||||
|
<span>Page scroll</span>
|
||||||
|
<div class="flex-1 h-1 rounded-full bg-(--color-surface-3) overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-(--color-brand)/50 transition-all"
|
||||||
|
style="width: {Math.round(scrollProgress * 100)}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="tabular-nums">{Math.round(scrollProgress * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if layout.readMode === 'paginated' && totalPages > 1}
|
||||||
|
<div class="mt-2 flex items-center gap-2 text-xs text-(--color-muted)">
|
||||||
|
<span>Page</span>
|
||||||
|
<div class="flex-1 h-1 rounded-full bg-(--color-surface-3) overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-(--color-brand)/50 transition-all"
|
||||||
|
style="width: {Math.round(((pageIndex + 1) / totalPages) * 100)}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="tabular-nums">{pageIndex + 1}/{totalPages}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Card 3: Chapter ToC -->
|
||||||
|
{#if data.chapters.length > 0}
|
||||||
|
{@const tocChapters = data.chapters}
|
||||||
|
{@const currentIdx = tocChapters.findIndex(c => c.number === data.chapter.number)}
|
||||||
|
{@const windowStart = Math.max(0, currentIdx - 3)}
|
||||||
|
{@const windowEnd = Math.min(tocChapters.length, windowStart + 10)}
|
||||||
|
<div class="rounded-xl bg-(--color-surface-2) border border-(--color-border) overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-3 py-2.5 border-b border-(--color-border)">
|
||||||
|
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider">Chapters</p>
|
||||||
|
<a
|
||||||
|
href="/books/{data.book.slug}/chapters"
|
||||||
|
class="text-[10px] text-(--color-brand) hover:underline"
|
||||||
|
>All {tocChapters.length}</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col divide-y divide-(--color-border)/50 max-h-64 overflow-y-auto">
|
||||||
|
{#each tocChapters.slice(windowStart, windowEnd) as ch}
|
||||||
|
{@const isCurrent = ch.number === data.chapter.number}
|
||||||
|
<a
|
||||||
|
href="/books/{data.book.slug}/chapters/{ch.number}"
|
||||||
|
class="flex items-start gap-2 px-3 py-2 text-xs transition-colors
|
||||||
|
{isCurrent
|
||||||
|
? 'bg-(--color-brand)/10 text-(--color-brand) font-semibold'
|
||||||
|
: 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)'}"
|
||||||
|
>
|
||||||
|
<span class="shrink-0 tabular-nums w-6 text-right opacity-60">{ch.number}</span>
|
||||||
|
<span class="truncate leading-snug">{ch.title || `Chapter ${ch.number}`}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Card 4: Chapter navigation -->
|
||||||
|
<div class="rounded-xl bg-(--color-surface-2) border border-(--color-border) px-3 py-3 flex flex-col gap-2">
|
||||||
|
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider mb-0.5">Navigate</p>
|
||||||
|
{#if data.prev}
|
||||||
|
<a
|
||||||
|
href="/books/{data.book.slug}/chapters/{data.prev}"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0" 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>
|
||||||
|
<span class="truncate">Chapter {data.prev}</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="flex items-center gap-2 px-3 py-2 text-xs text-(--color-muted)/30">
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0" 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>
|
||||||
|
First chapter
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if data.next}
|
||||||
|
<a
|
||||||
|
href="/books/{data.book.slug}/chapters/{data.next}"
|
||||||
|
class="flex items-center gap-2 px-3 py-2 rounded-lg text-xs text-(--color-brand) bg-(--color-brand)/10 hover:bg-(--color-brand)/20 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0" 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>
|
||||||
|
<span class="truncate">Chapter {data.next}</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="flex items-center gap-2 px-3 py-2 text-xs text-(--color-muted)/30">
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0" 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>
|
||||||
|
Last chapter
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</div><!-- end grid wrapper -->
|
||||||
|
|
||||||
<!-- ── Scroll mode floating nav buttons ──────────────────────────────────── -->
|
<!-- ── Scroll mode floating nav buttons ──────────────────────────────────── -->
|
||||||
{#if layout.readMode === 'scroll' && !layout.focusMode}
|
{#if layout.readMode === 'scroll' && !layout.focusMode}
|
||||||
{@const atTop = scrollProgress <= 0.01}
|
{@const atTop = scrollProgress <= 0.01}
|
||||||
@@ -1227,6 +1397,17 @@
|
|||||||
<span class="text-(--color-muted) text-[11px]">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
|
<span class="text-(--color-muted) text-[11px]">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => setLayout('showSidebar', !layout.showSidebar)}
|
||||||
|
class="w-full flex items-center justify-between px-3 py-2.5 text-xs font-medium transition-colors
|
||||||
|
{layout.showSidebar ? 'text-(--color-brand)' : 'text-(--color-text) hover:text-(--color-brand)'}"
|
||||||
|
aria-pressed={layout.showSidebar}
|
||||||
|
>
|
||||||
|
<span>Sidebar</span>
|
||||||
|
<span class="text-(--color-muted) text-[11px]">{layout.showSidebar ? 'On — ToC, progress & nav' : 'Off'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user