Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68c7ae55e7 |
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user