649 lines
35 KiB
Svelte
649 lines
35 KiB
Svelte
<script lang="ts">
|
|
import { browser } from '$app/environment';
|
|
import { goto } from '$app/navigation';
|
|
import { audioStore } from '$lib/audio.svelte';
|
|
import type { PageData } from './$types';
|
|
import * as m from '$lib/paraglide/messages.js';
|
|
|
|
let { data }: { data: PageData } = $props();
|
|
|
|
// ── Section visibility ────────────────────────────────────────────────────────
|
|
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read' | 'ready-to-listen';
|
|
const SECTIONS_KEY = 'home_sections_v1';
|
|
|
|
function loadHidden(): Set<SectionId> {
|
|
if (!browser) return new Set();
|
|
try {
|
|
const raw = localStorage.getItem(SECTIONS_KEY);
|
|
if (raw) return new Set(JSON.parse(raw) as SectionId[]);
|
|
} catch { /* ignore */ }
|
|
return new Set();
|
|
}
|
|
|
|
let hidden = $state<Set<SectionId>>(loadHidden());
|
|
|
|
function hide(id: SectionId) {
|
|
hidden = new Set([...hidden, id]);
|
|
if (browser) localStorage.setItem(SECTIONS_KEY, JSON.stringify([...hidden]));
|
|
}
|
|
|
|
function restore(id: SectionId) {
|
|
const next = new Set(hidden);
|
|
next.delete(id);
|
|
hidden = next;
|
|
if (browser) localStorage.setItem(SECTIONS_KEY, JSON.stringify([...next]));
|
|
}
|
|
|
|
const SECTION_LABELS = $derived<Record<SectionId, string>>({
|
|
'recently-updated': 'Recently Updated',
|
|
'browse-genre': 'Browse by Genre',
|
|
'from-following': 'From Following',
|
|
'trending': 'Trending Now',
|
|
'because-you-read': data.topGenre ? `Because you read ${data.topGenre}` : 'Recommendations',
|
|
'ready-to-listen': 'Ready to Listen',
|
|
});
|
|
|
|
const hiddenList = $derived(
|
|
(Object.keys(SECTION_LABELS) as SectionId[]).filter((id) => hidden.has(id))
|
|
);
|
|
|
|
function parseGenres(genres: string[] | string | null | undefined): string[] {
|
|
if (!genres) return [];
|
|
if (Array.isArray(genres)) return genres;
|
|
try {
|
|
const parsed = JSON.parse(genres);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch { return []; }
|
|
}
|
|
|
|
const dedupedRecent = $derived.by(() => {
|
|
const seen = new Map<string, { book: (typeof data.recentlyUpdated)[0]; count: number }>();
|
|
for (const book of data.recentlyUpdated) {
|
|
if (seen.has(book.slug)) {
|
|
seen.get(book.slug)!.count++;
|
|
} else {
|
|
seen.set(book.slug, { book, count: 1 });
|
|
}
|
|
}
|
|
return [...seen.values()];
|
|
});
|
|
|
|
const GENRES = [
|
|
'Action', 'Fantasy', 'Romance', 'Cultivation', 'System',
|
|
'Reincarnation', 'Sci-Fi', 'Horror', 'Slice of Life', 'Adventure',
|
|
];
|
|
|
|
// ── Hero carousel ────────────────────────────────────────────────────────
|
|
const CAROUSEL_INTERVAL = 6000; // ms
|
|
const heroBooks = $derived(data.continueInProgress);
|
|
let heroIndex = $state(0);
|
|
const heroBook = $derived(heroBooks[heroIndex] ?? null);
|
|
// 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 heroDot(i: number) {
|
|
heroIndex = i;
|
|
resetAutoAdvance();
|
|
}
|
|
|
|
// 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);
|
|
|
|
$effect(() => {
|
|
if (heroBooks.length <= 1) return;
|
|
const len = heroBooks.length;
|
|
void autoAdvanceSeed; // restart when seed changes
|
|
const id = setInterval(() => {
|
|
heroIndex = (heroIndex + 1) % len;
|
|
}, CAROUSEL_INTERVAL);
|
|
return () => clearInterval(id);
|
|
});
|
|
|
|
function resetAutoAdvance() {
|
|
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();
|
|
}
|
|
|
|
function playChapter(slug: string, chapter: number) {
|
|
audioStore.autoStartChapter = chapter;
|
|
goto(`/books/${slug}/chapters/${chapter}`);
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{m.home_title()}</title>
|
|
</svelte:head>
|
|
|
|
<!-- ── Hero carousel ──────────────────────────────────────────────────────────── -->
|
|
{#if heroBook}
|
|
{@const stackBooks = heroBooks.length > 1
|
|
? Array.from({ length: Math.min(heroBooks.length - 1, 3) }, (_, i) =>
|
|
heroBooks[(heroIndex + 1 + i) % heroBooks.length])
|
|
: []}
|
|
<section class="mb-6">
|
|
<!-- Outer flex row: front card + queued book spines (sm+ only) -->
|
|
<div class="relative flex items-stretch gap-0">
|
|
|
|
<!-- Front 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 z-[2] flex-1 min-w-0"
|
|
ontouchstart={onSwipeStart}
|
|
ontouchend={onSwipeEnd}
|
|
>
|
|
<!-- Cover — drives card height via aspect-[2/3] -->
|
|
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
|
class="w-32 sm:w-44 shrink-0 self-stretch overflow-hidden block">
|
|
{#if heroBook.book.cover}
|
|
{#key heroIndex}
|
|
<img src={heroBook.book.cover} alt={heroBook.book.title}
|
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 animate-fade-in" loading="eager" />
|
|
{/key}
|
|
{:else}
|
|
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
|
<svg class="w-10 h-10 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
|
</svg>
|
|
</div>
|
|
{/if}
|
|
</a>
|
|
|
|
<!-- Info — fixed height matching cover, overflow hidden so text never expands the card -->
|
|
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0 flex-1 overflow-hidden
|
|
h-[calc(128px*3/2)] sm:h-[calc(176px*3/2)]">
|
|
<div class="min-h-0 overflow-hidden">
|
|
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">{m.home_continue_reading()}</p>
|
|
<h2 class="text-xl sm:text-2xl font-bold text-(--color-text) leading-snug line-clamp-2 mb-1">{heroBook.book.title}</h2>
|
|
{#if heroBook.book.author}
|
|
<p class="text-sm text-(--color-muted) truncate">{heroBook.book.author}</p>
|
|
{/if}
|
|
{#if heroBook.book.summary}
|
|
<p class="hidden sm:block text-sm text-(--color-muted) mt-3 line-clamp-3 max-w-prose">{heroBook.book.summary}</p>
|
|
{/if}
|
|
</div>
|
|
<div class="flex items-center gap-3 mt-4 flex-wrap shrink-0">
|
|
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
|
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
|
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
|
{m.home_chapter_badge({ n: String(heroBook.chapter) })}
|
|
</a>
|
|
<a
|
|
href="/books/{heroBook.book.slug}"
|
|
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-surface-3) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 font-semibold text-sm transition-colors"
|
|
title="Book info"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20A10 10 0 0012 2z"/>
|
|
</svg>
|
|
Info
|
|
</a>
|
|
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
|
|
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Queued book spines — visible sm+ only, peek to the right of the front card -->
|
|
{#each stackBooks as stackBook, i}
|
|
{@const opacity = i === 0 ? 'opacity-70' : 'opacity-40'}
|
|
{@const width = i === 0 ? 'sm:w-10' : 'sm:w-7'}
|
|
<a
|
|
href="/books/{stackBook.book.slug}/chapters/{stackBook.chapter}"
|
|
class="hidden sm:block shrink-0 {width} rounded-r-xl overflow-hidden border border-l-0 border-(--color-border) {opacity} hover:opacity-90 transition-opacity"
|
|
aria-label={stackBook.book.title}
|
|
tabindex="-1"
|
|
>
|
|
{#if stackBook.book.cover}
|
|
<img src={stackBook.book.cover} alt="" aria-hidden="true"
|
|
class="w-full h-full object-cover object-left" loading="lazy" />
|
|
{:else}
|
|
<div class="w-full h-full bg-(--color-surface-3)"></div>
|
|
{/if}
|
|
</a>
|
|
{/each}
|
|
|
|
</div>
|
|
|
|
<!-- Dot indicators -->
|
|
{#if heroBooks.length > 1}
|
|
<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}"
|
|
>
|
|
<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)'}"></span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
|
|
<!-- ── Streak widget ───────────────────────────────────────────────────────────── -->
|
|
{#if streak > 0}
|
|
<div class="mb-6 flex items-center gap-3 flex-wrap text-sm">
|
|
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
|
<span class="font-semibold text-(--color-text)">{streak}</span>
|
|
<span class="text-(--color-muted)">day{streak !== 1 ? 's' : ''} reading</span>
|
|
</span>
|
|
{#if data.stats.booksInProgress > 0}
|
|
<span class="text-(--color-muted)">
|
|
<span class="font-semibold text-(--color-text)">{data.stats.booksInProgress}</span> {data.stats.booksInProgress === 1 ? 'book' : 'books'} in progress
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- ── Continue Reading shelf ──────────────────────────────────────────────────── -->
|
|
{#if shelfBooks.length > 0}
|
|
<section class="mb-10">
|
|
<div class="flex items-baseline justify-between mb-3">
|
|
<h2 class="text-base font-bold text-(--color-text)">{m.home_continue_reading()}</h2>
|
|
<a href="/books" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
|
</div>
|
|
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
|
{#each shelfBooks as { book, chapter }}
|
|
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-32 sm:w-36">
|
|
<a href="/books/{book.slug}/chapters/{chapter}" class="block">
|
|
<div class="aspect-[2/3] overflow-hidden relative">
|
|
{#if book.cover}
|
|
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
|
{:else}
|
|
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
|
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
|
</div>
|
|
{/if}
|
|
<!-- Chapter badge -->
|
|
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
|
|
{m.home_chapter_badge({ n: String(chapter) })}
|
|
</span>
|
|
</div>
|
|
</a>
|
|
<!-- Listen button (hover overlay) -->
|
|
<button
|
|
type="button"
|
|
onclick={() => playChapter(book.slug, chapter)}
|
|
class="absolute bottom-8 left-1.5 w-7 h-7 rounded-full bg-black/60 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
title="Listen"
|
|
aria-label="Listen to chapter {chapter}"
|
|
>
|
|
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
|
</button>
|
|
<a href="/books/{book.slug}/chapters/{chapter}" class="p-2 block">
|
|
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
|
</a>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
<!-- ── Completed shelf ────────────────────────────────────────────────────────── -->
|
|
{#if data.continueCompleted.length > 0}
|
|
<section class="mb-10">
|
|
<div class="flex items-baseline justify-between mb-3">
|
|
<h2 class="text-base font-bold text-(--color-text)">Completed</h2>
|
|
</div>
|
|
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
|
{#each data.continueCompleted as { book, chapter }}
|
|
<a href="/books/{book.slug}"
|
|
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-32 sm:w-36">
|
|
<div class="aspect-[2/3] overflow-hidden relative">
|
|
{#if book.cover}
|
|
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
|
{:else}
|
|
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
|
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
|
</div>
|
|
{/if}
|
|
<span class="absolute top-1.5 right-1.5 text-xs bg-green-600/90 text-white font-bold px-1.5 py-0.5 rounded">Done</span>
|
|
</div>
|
|
<div class="p-2">
|
|
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
|
{#if book.total_chapters > 0}
|
|
<p class="text-xs text-(--color-muted) mt-0.5">{chapter} chapters</p>
|
|
{/if}
|
|
</div>
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
<!-- ── Ready to Listen shelf ──────────────────────────────────────────────────── -->
|
|
{#if data.readyToListen.length > 0 && !hidden.has('ready-to-listen')}
|
|
<section class="mb-10">
|
|
<div class="flex items-baseline justify-between mb-3">
|
|
<h2 class="text-base font-bold text-(--color-text)">Ready to Listen</h2>
|
|
<div class="flex items-center gap-3">
|
|
<a href="/listen" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">View all</a>
|
|
<button type="button" onclick={() => hide('ready-to-listen')} title="Hide section"
|
|
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
|
{#each data.readyToListen as { book, audioChapters }}
|
|
{@const genres = parseGenres(book.genres)}
|
|
<div class="group relative flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
|
|
<a href="/books/{book.slug}" class="block">
|
|
<div class="aspect-[2/3] overflow-hidden relative">
|
|
{#if book.cover}
|
|
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
|
{:else}
|
|
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
|
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
|
</div>
|
|
{/if}
|
|
<!-- Headphones badge -->
|
|
<span class="absolute bottom-1.5 left-1.5 inline-flex items-center gap-1 text-xs bg-(--color-brand)/90 text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
|
|
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M12 3a9 9 0 00-9 9v5a3 3 0 003 3h1a1 1 0 001-1v-4a1 1 0 00-1-1H5v-2a7 7 0 0114 0v2h-2a1 1 0 00-1 1v4a1 1 0 001 1h1a3 3 0 003-3v-5a9 9 0 00-9-9z"/></svg>
|
|
{audioChapters} ch
|
|
</span>
|
|
</div>
|
|
</a>
|
|
<div class="p-2 flex flex-col gap-1 flex-1">
|
|
<a href="/books/{book.slug}" class="block">
|
|
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
|
</a>
|
|
{#if genres.length > 0}
|
|
<div class="flex flex-wrap gap-1 mt-auto pt-0.5">
|
|
{#each genres.slice(0, 2) as genre}
|
|
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<!-- Listen Ch.1 button -->
|
|
<button
|
|
type="button"
|
|
onclick={() => playChapter(book.slug, 1)}
|
|
class="mx-2 mb-2 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md bg-(--color-brand)/15 hover:bg-(--color-brand)/30 text-(--color-brand) text-xs font-semibold transition-colors"
|
|
aria-label="Listen from chapter 1"
|
|
>
|
|
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
|
Listen
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
<!-- ── Genre discovery strip ─────────────────────────────────────────────────── -->
|
|
{#if !hidden.has('browse-genre')}
|
|
<section class="mb-10">
|
|
<div class="flex items-baseline justify-between mb-3">
|
|
<h2 class="text-base font-bold text-(--color-text)">Browse by genre</h2>
|
|
<div class="flex items-center gap-3">
|
|
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
|
<button type="button" onclick={() => hide('browse-genre')} title="Hide section"
|
|
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 overflow-x-auto pb-1 scrollbar-none -mx-4 px-4">
|
|
{#each GENRES as genre}
|
|
<a href="/catalogue?genre={encodeURIComponent(genre)}"
|
|
class="shrink-0 px-3.5 py-1.5 rounded-full border border-(--color-border) bg-(--color-surface-2) text-sm text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors whitespace-nowrap">
|
|
{genre}
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
<!-- ── Trending Now ───────────────────────────────────────────────────────────── -->
|
|
{#if data.trendingBooks.length > 0 && !hidden.has('trending')}
|
|
<section class="mb-10">
|
|
<div class="flex items-baseline justify-between mb-3">
|
|
<h2 class="text-base font-bold text-(--color-text)">Trending Now</h2>
|
|
<div class="flex items-center gap-3">
|
|
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
|
<button type="button" onclick={() => hide('trending')} title="Hide section"
|
|
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
|
{#each data.trendingBooks as book}
|
|
{@const genres = parseGenres(book.genres)}
|
|
<a href="/books/{book.slug}"
|
|
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
|
|
<div class="aspect-[2/3] overflow-hidden relative">
|
|
{#if book.cover}
|
|
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
|
{:else}
|
|
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
|
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
|
</div>
|
|
{/if}
|
|
<span class="absolute top-1.5 left-1.5 text-xs bg-(--color-brand)/80 text-(--color-surface) font-bold px-1.5 py-0.5 rounded">#{book.ranking}</span>
|
|
</div>
|
|
<div class="p-2 flex flex-col gap-1">
|
|
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
|
{#if book.author}
|
|
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
|
|
{/if}
|
|
{#if genres.length > 0}
|
|
<div class="flex flex-wrap gap-1 mt-auto pt-0.5">
|
|
{#each genres.slice(0, 2) as genre}
|
|
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
<!-- ── Because you read [Genre] ──────────────────────────────────────────────── -->
|
|
{#if data.recommendedBooks.length > 0 && data.topGenre && !hidden.has('because-you-read')}
|
|
<section class="mb-10">
|
|
<div class="flex items-baseline justify-between mb-3">
|
|
<h2 class="text-base font-bold text-(--color-text)">
|
|
Because you read <span class="text-(--color-brand)">{data.topGenre}</span>
|
|
</h2>
|
|
<button type="button" onclick={() => hide('because-you-read')} title="Hide section"
|
|
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
|
{#each data.recommendedBooks as book}
|
|
{@const genres = parseGenres(book.genres)}
|
|
<a href="/books/{book.slug}"
|
|
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
|
|
<div class="aspect-[2/3] overflow-hidden relative">
|
|
{#if book.cover}
|
|
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
|
{:else}
|
|
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
|
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div class="p-2 flex flex-col gap-1">
|
|
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
|
{#if book.author}
|
|
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
|
|
{/if}
|
|
{#if genres.length > 0}
|
|
<div class="flex flex-wrap gap-1 mt-auto pt-0.5">
|
|
{#each genres.slice(0, 2) as genre}
|
|
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
<!-- ── Recently Updated ──────────────────────────────────────────────────────── -->
|
|
{#if dedupedRecent.length > 0 && !hidden.has('recently-updated')}
|
|
<section class="mb-10">
|
|
<div class="flex items-baseline justify-between mb-3">
|
|
<h2 class="text-base font-bold text-(--color-text)">{m.home_recently_updated()}</h2>
|
|
<div class="flex items-center gap-3">
|
|
<a href="/catalogue" class="text-xs text-(--color-brand) hover:text-(--color-brand-dim)">{m.home_view_all()}</a>
|
|
<button type="button" onclick={() => hide('recently-updated')} title="Hide section"
|
|
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
|
{#each dedupedRecent as { book, count }}
|
|
{@const genres = parseGenres(book.genres)}
|
|
<a href="/books/{book.slug}"
|
|
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
|
|
<div class="aspect-[2/3] overflow-hidden relative">
|
|
{#if book.cover}
|
|
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
|
{:else}
|
|
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
|
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
|
</div>
|
|
{/if}
|
|
{#if count > 1}
|
|
<span class="absolute top-1.5 left-1.5 text-xs bg-(--color-success)/90 text-black font-bold px-1.5 py-0.5 rounded">
|
|
+{count} ch.
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
<div class="p-2 flex flex-col gap-1">
|
|
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
|
{#if book.author}
|
|
<p class="text-xs text-(--color-muted) truncate">{book.author}</p>
|
|
{/if}
|
|
{#if genres.length > 0}
|
|
<div class="flex flex-wrap gap-1 mt-auto pt-0.5">
|
|
{#each genres.slice(0, 2) as genre}
|
|
<span class="text-xs px-1 py-0.5 rounded bg-(--color-surface) text-(--color-muted)">{genre}</span>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
<!-- ── From Following ────────────────────────────────────────────────────────── -->
|
|
{#if data.subscriptionFeed.length > 0 && !hidden.has('from-following')}
|
|
<section class="mb-10">
|
|
<div class="flex items-baseline justify-between mb-3">
|
|
<h2 class="text-base font-bold text-(--color-text)">{m.home_from_following()}</h2>
|
|
<button type="button" onclick={() => hide('from-following')} title="Hide section"
|
|
class="text-(--color-muted) hover:text-(--color-text) transition-colors">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
|
{#each data.subscriptionFeed as { book, readerUsername }}
|
|
<a href="/books/{book.slug}"
|
|
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) border border-(--color-border) hover:border-(--color-brand)/40 transition-all shrink-0 w-36 sm:w-40">
|
|
<div class="aspect-[2/3] overflow-hidden">
|
|
{#if book.cover}
|
|
<img src={book.cover} alt={book.title} class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
|
|
{:else}
|
|
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
|
|
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div class="p-2 flex flex-col gap-0.5">
|
|
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
|
<p class="text-xs text-(--color-muted) truncate">{m.home_via_reader({ username: readerUsername })}</p>
|
|
</div>
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
|
|
<!-- ── Empty state ───────────────────────────────────────────────────────────── -->
|
|
{#if data.continueInProgress.length === 0 && data.continueCompleted.length === 0 && dedupedRecent.length === 0}
|
|
<div class="text-center py-20 text-(--color-muted)">
|
|
<p class="text-lg font-semibold text-(--color-text) mb-2">{m.home_empty_title()}</p>
|
|
<p class="text-sm mb-6">{m.home_empty_body()}</p>
|
|
<a href="/catalogue" class="inline-block px-6 py-3 bg-(--color-brand) text-(--color-surface) font-semibold rounded-lg hover:bg-(--color-brand-dim) transition-colors">
|
|
{m.home_discover_novels()}
|
|
</a>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- ── Hidden sections restore ───────────────────────────────────────────────── -->
|
|
{#if hiddenList.length > 0}
|
|
<div class="mb-6 flex flex-wrap items-center gap-2">
|
|
<span class="text-xs text-(--color-muted)">Hidden:</span>
|
|
{#each hiddenList as id}
|
|
<button type="button" onclick={() => restore(id)}
|
|
class="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full border border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 transition-colors">
|
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
|
</svg>
|
|
{SECTION_LABELS[id]}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- ── Stats footer ──────────────────────────────────────────────────────────── -->
|
|
<div class="mt-6 pt-6 border-t border-(--color-border) flex items-center justify-center gap-6 text-sm text-(--color-muted) flex-wrap">
|
|
<span><span class="font-semibold text-(--color-text)">{data.stats.totalBooks.toLocaleString()}</span> {m.home_stat_books()}</span>
|
|
<span class="w-px h-4 bg-(--color-border)"></span>
|
|
<span><span class="font-semibold text-(--color-text)">{data.stats.totalChapters.toLocaleString()}</span> {m.home_stat_chapters()}</span>
|
|
{#if streak > 0}
|
|
<span class="w-px h-4 bg-(--color-border)"></span>
|
|
<span><span class="font-semibold text-(--color-text)">{streak}</span> day streak</span>
|
|
{/if}
|
|
</div>
|