feat(home): add streak widget, trending, genre recs, completed shelf, audio quick-play
- Reading streak + books-in-progress mini-widget (derived from progress timestamps) - "N chapters left" badge on continue-reading shelf cards - Audio listen button on hero card and hover-overlay on shelf cards (autoStartChapter + goto) - Completed shelf section for books where chapter >= total_chapters - Trending Now section (books sorted by ranking field, 15-min cache) - "Because you read [Genre]" recommendations (genre-matched, excludes user's books, 10-min cache) - Both new sections are hideable via the existing show/hide mechanism - getTrendingBooks / getRecommendedBooks added to pocketbase.ts - Cache invalidation for trending/recs added to invalidateBooksCache Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -331,10 +331,46 @@ export async function invalidateBooksCache(): Promise<void> {
|
||||
cache.invalidate(BOOKS_CACHE_KEY),
|
||||
cache.invalidate(HOME_STATS_CACHE_KEY),
|
||||
cache.invalidatePattern('books:recent:*'),
|
||||
cache.invalidatePattern('books:recently-updated:*')
|
||||
cache.invalidatePattern('books:recently-updated:*'),
|
||||
cache.invalidatePattern('books:trending:*'),
|
||||
cache.invalidatePattern('books:recs:*')
|
||||
]);
|
||||
}
|
||||
|
||||
/** Books sorted by ranking (lower = more popular). Excludes unranked (ranking=0). */
|
||||
export async function getTrendingBooks(limit = 8): Promise<Book[]> {
|
||||
const key = `books:trending:${limit}`;
|
||||
const cached = await cache.get<Book[]>(key);
|
||||
if (cached) return cached;
|
||||
const books = await listN<Book>('books', limit, 'ranking>0', '+ranking');
|
||||
await cache.set(key, books, 15 * 60);
|
||||
return books;
|
||||
}
|
||||
|
||||
/**
|
||||
* Books matching the given genres that the user hasn't read yet.
|
||||
* The raw genre-query result is cached (shared across users); per-user slug
|
||||
* exclusion is applied in memory afterwards.
|
||||
*/
|
||||
export async function getRecommendedBooks(
|
||||
topGenres: string[],
|
||||
excludeSlugs: Set<string>,
|
||||
limit = 8
|
||||
): Promise<Book[]> {
|
||||
if (topGenres.length === 0) return [];
|
||||
const sortedGenres = [...topGenres].sort();
|
||||
const key = `books:recs:${sortedGenres.join(':')}:${limit}`;
|
||||
let books = await cache.get<Book[]>(key);
|
||||
if (!books) {
|
||||
const genreFilter = sortedGenres
|
||||
.map((g) => `genres~"${g.replace(/"/g, '')}"`)
|
||||
.join('||');
|
||||
books = await listN<Book>('books', limit * 4, genreFilter, '+ranking');
|
||||
await cache.set(key, books, 10 * 60);
|
||||
}
|
||||
return books.filter((b) => !excludeSlugs.has(b.slug)).slice(0, limit);
|
||||
}
|
||||
|
||||
export async function getBook(slug: string): Promise<Book | null> {
|
||||
return listOne<Book>('books', `slug="${slug}"`);
|
||||
}
|
||||
@@ -1122,12 +1158,14 @@ export interface UserSession {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a short device fingerprint from user-agent + IP.
|
||||
* SHA-256 of the concatenation, first 16 hex chars.
|
||||
* Generate a short device fingerprint from the user-agent alone.
|
||||
* IP is intentionally excluded so that network changes (VPN, mobile data,
|
||||
* home vs. office wifi) don't create duplicate sessions for the same device.
|
||||
* SHA-256 of the user-agent string, first 16 hex chars.
|
||||
*/
|
||||
function deviceFingerprint(userAgent: string, ip: string): string {
|
||||
function deviceFingerprint(userAgent: string, _ip?: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`${userAgent}::${ip}`)
|
||||
.update(userAgent)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
}
|
||||
@@ -1154,12 +1192,13 @@ export async function upsertUserSession(
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
// Touch last_seen and return the existing authSessionId
|
||||
// Touch last_seen and update IP (may have changed due to network switch).
|
||||
// Return the existing authSessionId so no new session row is created.
|
||||
const token = await getToken();
|
||||
await fetch(`${PB_URL}/api/collections/user_sessions/records/${existing.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ last_seen: new Date().toISOString() })
|
||||
body: JSON.stringify({ last_seen: new Date().toISOString(), ip })
|
||||
}).catch(() => {});
|
||||
return { authSessionId: existing.session_id, recordId: existing.id };
|
||||
}
|
||||
|
||||
@@ -4,15 +4,35 @@ import {
|
||||
recentlyUpdatedBooks,
|
||||
allProgress,
|
||||
getHomeStats,
|
||||
getSubscriptionFeed
|
||||
getSubscriptionFeed,
|
||||
getTrendingBooks,
|
||||
getRecommendedBooks
|
||||
} from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
import type { Book, Progress } from '$lib/server/pocketbase';
|
||||
|
||||
function parseGenresLocal(genres: string[] | string | null | undefined): string[] {
|
||||
if (!genres) return [];
|
||||
if (Array.isArray(genres)) return genres;
|
||||
try { return JSON.parse(genres) as string[]; } catch { return []; }
|
||||
}
|
||||
|
||||
function computeStreak(progressList: Progress[]): number {
|
||||
const days = new Set(
|
||||
progressList.filter((p) => p.updated).map((p) => p.updated.slice(0, 10))
|
||||
);
|
||||
let streak = 0;
|
||||
const today = new Date();
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const d = new Date(today);
|
||||
d.setUTCDate(d.getUTCDate() - i);
|
||||
if (days.has(d.toISOString().slice(0, 10))) streak++;
|
||||
else if (i > 0) break;
|
||||
}
|
||||
return streak;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
// Step 1: fetch progress + recent books + stats in parallel.
|
||||
// We intentionally do NOT call listBooks() here — we only need books that
|
||||
// appear in the user's progress list, which is a tiny subset of 15k books.
|
||||
let recentBooks: Book[] = [];
|
||||
let progressList: Progress[] = [];
|
||||
let stats = { totalBooks: 0, totalChapters: 0 };
|
||||
@@ -27,8 +47,9 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
log.error('home', 'failed to load home data', { err: String(e) });
|
||||
}
|
||||
|
||||
// Step 2: fetch only the books we actually need for continue-reading.
|
||||
// This is O(progress entries) instead of O(15k books).
|
||||
const streak = computeStreak(progressList);
|
||||
|
||||
// Fetch only the books we need for continue-reading (avoid loading all books)
|
||||
const progressSlugs = progressList.map((p) => p.slug);
|
||||
const progressBooks = progressSlugs.length > 0
|
||||
? await getBooksBySlugs(progressSlugs).catch(() => [] as Book[])
|
||||
@@ -36,31 +57,65 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
|
||||
const bookMap = new Map<string, Book>(progressBooks.map((b) => [b.slug, b]));
|
||||
|
||||
// Continue reading: progress entries joined with book data, most recent first
|
||||
// All continue-reading entries joined with book data, most recent first
|
||||
const continueReading = progressList
|
||||
.filter((p) => bookMap.has(p.slug))
|
||||
.slice(0, 6)
|
||||
.slice(0, 8)
|
||||
.map((p) => ({ book: bookMap.get(p.slug)!, chapter: p.chapter }));
|
||||
|
||||
// Recently updated: deduplicate against continueReading slugs
|
||||
// Split into in-progress vs completed
|
||||
const continueInProgress = continueReading.filter(
|
||||
({ book, chapter }) => book.total_chapters === 0 || chapter < book.total_chapters
|
||||
);
|
||||
const continueCompleted = continueReading.filter(
|
||||
({ book, chapter }) => book.total_chapters > 0 && chapter >= book.total_chapters
|
||||
);
|
||||
|
||||
// Top genres from books the user has been reading
|
||||
const genreFreq = new Map<string, number>();
|
||||
for (const { book } of continueReading) {
|
||||
for (const g of parseGenresLocal(book.genres)) {
|
||||
genreFreq.set(g, (genreFreq.get(g) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const topGenres = [...genreFreq.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([g]) => g);
|
||||
|
||||
// Deduplicate recently-updated against in-progress slugs
|
||||
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
|
||||
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
|
||||
|
||||
// Subscription feed — only when logged in
|
||||
const subscriptionFeed = locals.user
|
||||
? await getSubscriptionFeed(locals.user.id, 12).catch((e) => {
|
||||
log.error('home', 'failed to load subscription feed', { err: String(e) });
|
||||
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
|
||||
})
|
||||
: [];
|
||||
// Fetch trending, recommendations, and subscription feed in parallel
|
||||
const [trendingBooks, recommendedBooks, subscriptionFeed] = await Promise.all([
|
||||
getTrendingBooks(8).catch(() => [] as Book[]),
|
||||
topGenres.length > 0
|
||||
? getRecommendedBooks(topGenres, inProgressSlugs, 8).catch(() => [] as Book[])
|
||||
: Promise.resolve([] as Book[]),
|
||||
locals.user
|
||||
? getSubscriptionFeed(locals.user.id, 12).catch((e) => {
|
||||
log.error('home', 'failed to load subscription feed', { err: String(e) });
|
||||
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
|
||||
})
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
// Strip books the user is already reading from trending (redundant)
|
||||
const trendingFiltered = trendingBooks.filter((b) => !inProgressSlugs.has(b.slug));
|
||||
|
||||
return {
|
||||
continueReading,
|
||||
continueInProgress,
|
||||
continueCompleted,
|
||||
recentlyUpdated,
|
||||
subscriptionFeed,
|
||||
trendingBooks: trendingFiltered,
|
||||
recommendedBooks,
|
||||
topGenre: topGenres[0] ?? null,
|
||||
stats: {
|
||||
...stats,
|
||||
booksInProgress: continueReading.length
|
||||
booksInProgress: continueInProgress.length,
|
||||
streak
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
<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 (localStorage, Svelte 5 runes) ────────────────────────
|
||||
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following';
|
||||
// ── Section visibility ────────────────────────────────────────────────────────
|
||||
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read';
|
||||
const SECTIONS_KEY = 'home_sections_v1';
|
||||
|
||||
const SECTION_LABELS: Record<SectionId, string> = {
|
||||
'recently-updated': 'Recently Updated',
|
||||
'browse-genre': 'Browse by Genre',
|
||||
'from-following': 'From Following',
|
||||
};
|
||||
|
||||
function loadHidden(): Set<SectionId> {
|
||||
if (!browser) return new Set();
|
||||
try {
|
||||
@@ -38,6 +34,14 @@
|
||||
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',
|
||||
});
|
||||
|
||||
const hiddenList = $derived(
|
||||
(Object.keys(SECTION_LABELS) as SectionId[]).filter((id) => hidden.has(id))
|
||||
);
|
||||
@@ -51,8 +55,6 @@
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// Deduplicate recentlyUpdated by slug, keeping the first occurrence and
|
||||
// counting how many times the same book appears (= new chapters added).
|
||||
const dedupedRecent = $derived.by(() => {
|
||||
const seen = new Map<string, { book: (typeof data.recentlyUpdated)[0]; count: number }>();
|
||||
for (const book of data.recentlyUpdated) {
|
||||
@@ -70,9 +72,14 @@
|
||||
'Reincarnation', 'Sci-Fi', 'Horror', 'Slice of Life', 'Adventure',
|
||||
];
|
||||
|
||||
// Hero = first continue-reading item; shelf = the rest
|
||||
const heroBook = $derived(data.continueReading[0] ?? null);
|
||||
const shelfBooks = $derived(data.continueReading.slice(1));
|
||||
const heroBook = $derived(data.continueInProgress[0] ?? null);
|
||||
const shelfBooks = $derived(data.continueInProgress.slice(1));
|
||||
const streak = $derived(data.stats.streak ?? 0);
|
||||
|
||||
function playChapter(slug: string, chapter: number) {
|
||||
audioStore.autoStartChapter = chapter;
|
||||
goto(`/books/${slug}/chapters/${chapter}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -81,13 +88,11 @@
|
||||
|
||||
<!-- ── Hero resume card ──────────────────────────────────────────────────────── -->
|
||||
{#if heroBook}
|
||||
<section class="mb-10">
|
||||
<a
|
||||
href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
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"
|
||||
>
|
||||
<section class="mb-6">
|
||||
<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">
|
||||
<!-- Cover -->
|
||||
<div class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden">
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden block">
|
||||
{#if heroBook.book.cover}
|
||||
<img src={heroBook.book.cover} alt={heroBook.book.title}
|
||||
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" loading="eager" />
|
||||
@@ -98,7 +103,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0">
|
||||
@@ -113,20 +118,54 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-4 flex-wrap">
|
||||
<span class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm group-hover:bg-(--color-brand-dim) transition-colors">
|
||||
<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) })}
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(heroBook!.book.slug, heroBook!.chapter)}
|
||||
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="Listen to narration"
|
||||
>
|
||||
<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="M9 9a3 3 0 114 2.83V17m0 0a2 2 0 11-4 0m4 0H9m9-8a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
Listen
|
||||
</button>
|
||||
{#if heroBook.book.total_chapters > 0 && heroBook.chapter < heroBook.book.total_chapters}
|
||||
{@const ahead = heroBook.book.total_chapters - heroBook.chapter}
|
||||
<span class="text-xs text-(--color-muted) hidden sm:inline">{ahead} chapters ahead</span>
|
||||
{/if}
|
||||
{#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>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Continue Reading shelf (remaining books) ──────────────────────────────── -->
|
||||
<!-- ── 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)">
|
||||
<svg class="w-4 h-4 text-orange-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M13.5 0.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>
|
||||
</svg>
|
||||
<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">
|
||||
@@ -135,7 +174,56 @@
|
||||
</div>
|
||||
<div class="flex gap-3 overflow-x-auto pb-2 scrollbar-none -mx-4 px-4">
|
||||
{#each shelfBooks as { book, chapter }}
|
||||
<a href="/books/{book.slug}/chapters/{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>
|
||||
<!-- Chapters ahead badge -->
|
||||
{#if book.total_chapters > 0 && chapter < book.total_chapters}
|
||||
<span class="absolute top-1.5 left-1.5 text-xs bg-black/60 text-white font-medium px-1.5 py-0.5 rounded">
|
||||
{book.total_chapters - chapter} left
|
||||
</span>
|
||||
{/if}
|
||||
</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}
|
||||
@@ -145,12 +233,13 @@
|
||||
<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 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>
|
||||
<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}
|
||||
@@ -184,6 +273,102 @@
|
||||
</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">
|
||||
@@ -272,8 +457,8 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Empty state (no content at all) ──────────────────────────────────────── -->
|
||||
{#if data.continueReading.length === 0 && dedupedRecent.length === 0}
|
||||
<!-- ── 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>
|
||||
@@ -301,8 +486,12 @@
|
||||
{/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)">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user