Compare commits

...

1 Commits

Author SHA1 Message Date
root
68c7ae55e7 fix: carousel no longer reshuffles continue-reading shelf; remove arrows, add swipe; animate progress line under active dot
All checks were successful
Release / Test backend (push) Successful in 54s
Release / Check ui (push) Successful in 1m41s
Release / Docker (push) Successful in 5m50s
Release / Gitea Release (push) Successful in 29s
2026-04-07 12:32:45 +05:00

View File

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