feat: add sticky sidebar to chapter reader with ToC, progress, book info, and chapter nav
Some checks failed
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 2m2s
Release / Docker (push) Failing after 7m3s
Release / Gitea Release (push) Has been skipped

This commit is contained in:
root
2026-04-12 22:44:24 +05:00
parent 93cc0b6eb0
commit e6f7f7297d

View File

@@ -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>