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;
|
||||
playerStyle: PlayerStyle;
|
||||
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 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.
|
||||
*/
|
||||
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 {
|
||||
if (!browser) return DEFAULT_LAYOUT;
|
||||
@@ -466,6 +467,12 @@
|
||||
<div class="reading-progress" style="width: {scrollProgress * 100}%"></div>
|
||||
{/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) ─────────────────────────────── -->
|
||||
{#if !layout.focusMode}
|
||||
<div class="flex items-center justify-between mb-8 gap-2">
|
||||
@@ -864,6 +871,169 @@
|
||||
</div>
|
||||
{/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 ──────────────────────────────────── -->
|
||||
{#if layout.readMode === 'scroll' && !layout.focusMode}
|
||||
{@const atTop = scrollProgress <= 0.01}
|
||||
@@ -1227,6 +1397,17 @@
|
||||
<span class="text-(--color-muted) text-[11px]">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
|
||||
</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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user