|
|
|
|
@@ -73,50 +73,76 @@
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// ── Hero carousel ────────────────────────────────────────────────────────
|
|
|
|
|
const CAROUSEL_INTERVAL = 6000; // ms
|
|
|
|
|
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) : []
|
|
|
|
|
);
|
|
|
|
|
// Shelf always shows books at positions 1…n — stable regardless of heroIndex
|
|
|
|
|
// so that navigating the carousel doesn't reshuffle the shelf below.
|
|
|
|
|
const shelfBooks = $derived(heroBooks.length > 1 ? heroBooks.slice(1) : []);
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Auto-advance carousel every 6 s when there are multiple books.
|
|
|
|
|
// We use a $state counter as a "restart token" so the $effect can be
|
|
|
|
|
// re-triggered by manual navigation without reading heroIndex (which would
|
|
|
|
|
// cause an infinite loop when the interval itself mutates heroIndex).
|
|
|
|
|
// Auto-advance carousel every CAROUSEL_INTERVAL ms when there are multiple books.
|
|
|
|
|
// autoAdvanceSeed is bumped on manual swipe/dot to restart the interval.
|
|
|
|
|
let autoAdvanceSeed = $state(0);
|
|
|
|
|
// progressStart tracks when the current interval began (for the progress bar).
|
|
|
|
|
let progressStart = $state(browser ? performance.now() : 0);
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (heroBooks.length <= 1) return;
|
|
|
|
|
// Subscribe to heroBooks.length and autoAdvanceSeed only — not heroIndex.
|
|
|
|
|
const len = heroBooks.length;
|
|
|
|
|
void autoAdvanceSeed; // track the seed
|
|
|
|
|
void autoAdvanceSeed; // restart when seed changes
|
|
|
|
|
progressStart = browser ? performance.now() : 0;
|
|
|
|
|
const id = setInterval(() => {
|
|
|
|
|
heroIndex = (heroIndex + 1) % len;
|
|
|
|
|
}, 6000);
|
|
|
|
|
progressStart = browser ? performance.now() : 0;
|
|
|
|
|
}, CAROUSEL_INTERVAL);
|
|
|
|
|
return () => clearInterval(id);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function resetAutoAdvance() {
|
|
|
|
|
// Bump the seed to restart the interval after manual navigation.
|
|
|
|
|
autoAdvanceSeed++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Swipe handling ───────────────────────────────────────────────────────
|
|
|
|
|
let swipeStartX = 0;
|
|
|
|
|
function onSwipeStart(e: TouchEvent) {
|
|
|
|
|
swipeStartX = e.touches[0].clientX;
|
|
|
|
|
}
|
|
|
|
|
function onSwipeEnd(e: TouchEvent) {
|
|
|
|
|
const dx = e.changedTouches[0].clientX - swipeStartX;
|
|
|
|
|
if (Math.abs(dx) < 40) return; // ignore tiny movements
|
|
|
|
|
if (dx < 0) {
|
|
|
|
|
// swipe left → next
|
|
|
|
|
heroIndex = (heroIndex + 1) % heroBooks.length;
|
|
|
|
|
} else {
|
|
|
|
|
// swipe right → prev
|
|
|
|
|
heroIndex = (heroIndex - 1 + heroBooks.length) % heroBooks.length;
|
|
|
|
|
}
|
|
|
|
|
resetAutoAdvance();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Progress bar animation ───────────────────────────────────────────────
|
|
|
|
|
// rAF loop drives a 0→1 progress value that resets on each advance.
|
|
|
|
|
let rafProgress = $state(0);
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (!browser || heroBooks.length <= 1) return;
|
|
|
|
|
void autoAdvanceSeed; // re-subscribe so effect re-runs on manual nav
|
|
|
|
|
void heroIndex;
|
|
|
|
|
let raf: number;
|
|
|
|
|
function tick() {
|
|
|
|
|
rafProgress = Math.min((performance.now() - progressStart) / CAROUSEL_INTERVAL, 1);
|
|
|
|
|
raf = requestAnimationFrame(tick);
|
|
|
|
|
}
|
|
|
|
|
raf = requestAnimationFrame(tick);
|
|
|
|
|
return () => cancelAnimationFrame(raf);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function playChapter(slug: string, chapter: number) {
|
|
|
|
|
audioStore.autoStartChapter = chapter;
|
|
|
|
|
goto(`/books/${slug}/chapters/${chapter}`);
|
|
|
|
|
@@ -131,8 +157,13 @@
|
|
|
|
|
{#if heroBook}
|
|
|
|
|
<section class="mb-6">
|
|
|
|
|
<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">
|
|
|
|
|
<!-- Card — swipe to navigate -->
|
|
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
|
|
|
<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"
|
|
|
|
|
ontouchstart={onSwipeStart}
|
|
|
|
|
ontouchend={onSwipeEnd}
|
|
|
|
|
>
|
|
|
|
|
<!-- Cover -->
|
|
|
|
|
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
|
|
|
|
class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden block">
|
|
|
|
|
@@ -188,44 +219,32 @@
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Prev / Next arrow buttons (only when multiple books) -->
|
|
|
|
|
{#if heroBooks.length > 1}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
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="2" d="M15 19l-7-7 7-7"/>
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
<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 -->
|
|
|
|
|
<!-- Dot indicators with animated progress line under active dot -->
|
|
|
|
|
{#if heroBooks.length > 1}
|
|
|
|
|
<div class="flex items-center justify-center gap-1.5 mt-2.5">
|
|
|
|
|
<div class="flex items-center justify-center gap-2 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
|
|
|
|
|
class="relative flex flex-col items-center gap-0.5 group/dot"
|
|
|
|
|
>
|
|
|
|
|
<!-- dot -->
|
|
|
|
|
<span class="block 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>
|
|
|
|
|
: 'w-1.5 h-1.5 bg-(--color-border) group-hover/dot:bg-(--color-muted)'}"></span>
|
|
|
|
|
<!-- progress line — only visible under the active dot -->
|
|
|
|
|
{#if i === heroIndex}
|
|
|
|
|
<span class="absolute -bottom-1.5 left-0 h-0.5 w-full bg-(--color-border) rounded-full overflow-hidden">
|
|
|
|
|
<span
|
|
|
|
|
class="block h-full bg-(--color-brand) rounded-full"
|
|
|
|
|
style="width: {rafProgress * 100}%"
|
|
|
|
|
></span>
|
|
|
|
|
</span>
|
|
|
|
|
{/if}
|
|
|
|
|
</button>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|