diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index d3594a9..1c26f63 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -211,6 +211,20 @@ async function listOne(collection: string, filter: string, sort = ''): Promis const BOOKS_CACHE_KEY = 'books:all'; const BOOKS_CACHE_TTL = 5 * 60; // 5 minutes +const RATINGS_CACHE_KEY = 'book_ratings:all'; +const RATINGS_CACHE_TTL = 5 * 60; // 5 minutes + +const HOME_STATS_CACHE_KEY = 'home:stats'; +const HOME_STATS_CACHE_TTL = 10 * 60; // 10 minutes — counts don't need to be exact + +async function getAllRatings(): Promise { + const cached = await cache.get(RATINGS_CACHE_KEY); + if (cached) return cached; + const ratings = await listAll('book_ratings', '').catch(() => [] as BookRating[]); + await cache.set(RATINGS_CACHE_KEY, ratings, RATINGS_CACHE_TTL); + return ratings; +} + export async function listBooks(): Promise { const cached = await cache.get(BOOKS_CACHE_KEY); if (cached) { @@ -282,7 +296,11 @@ export async function getBooksBySlugs(slugs: Iterable): Promise /** Invalidate the books cache (call after a book is created/updated/deleted). */ export async function invalidateBooksCache(): Promise { - await cache.invalidate(BOOKS_CACHE_KEY); + await Promise.all([ + cache.invalidate(BOOKS_CACHE_KEY), + cache.invalidate(HOME_STATS_CACHE_KEY), + cache.invalidatePattern('books:recent:*') + ]); } export async function getBook(slug: string): Promise { @@ -290,7 +308,12 @@ export async function getBook(slug: string): Promise { } export async function recentlyAddedBooks(limit = 6): Promise { - return listN('books', limit, '', '-meta_updated'); + const key = `books:recent:${limit}`; + const cached = await cache.get(key); + if (cached) return cached; + const books = await listN('books', limit, '', '-meta_updated'); + await cache.set(key, books, 5 * 60); // 5 minutes + return books; } export interface HomeStats { @@ -299,11 +322,19 @@ export interface HomeStats { } export async function getHomeStats(): Promise { + const cached = await cache.get(HOME_STATS_CACHE_KEY); + if (cached) return cached; const [totalBooks, totalChapters] = await Promise.all([ countCollection('books'), countCollection('chapters_idx') ]); - return { totalBooks, totalChapters }; + const stats = { totalBooks, totalChapters }; + await cache.set(HOME_STATS_CACHE_KEY, stats, HOME_STATS_CACHE_TTL); + return stats; +} + +export async function invalidateHomeStatsCache(): Promise { + await cache.invalidate(HOME_STATS_CACHE_KEY); } // ─── Chapter index ──────────────────────────────────────────────────────────── @@ -1849,6 +1880,7 @@ export async function setBookRating( } else { await pbPost('/api/collections/book_ratings/records', payload); } + await cache.invalidate(RATINGS_CACHE_KEY); } // ─── Shelves ─────────────────────────────────────────────────────────────────── @@ -1918,7 +1950,7 @@ export async function getBooksForDiscovery( // Fetch avg ratings for candidates, weight top-rated books to surface earlier. // Fetch in one shot for all candidate slugs. Low-rated / unrated books still // appear — they're just pushed further back via a stable sort before shuffle. - const ratingRows = await listAll('book_ratings', '').catch(() => [] as BookRating[]); + const ratingRows = await getAllRatings(); const ratingMap = new Map(); for (const r of ratingRows) { const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 }; diff --git a/ui/src/routes/discover/+page.svelte b/ui/src/routes/discover/+page.svelte index 7bc1e53..57d5847 100644 --- a/ui/src/routes/discover/+page.svelte +++ b/ui/src/routes/discover/+page.svelte @@ -130,7 +130,43 @@ let cardEl = $state(null); + // ── Card entry animation (prevents pop-to-full-size after swipe) ───────────── + let cardEntering = $state(false); + let entryTransition = $state(false); + let entryCleanup: ReturnType | null = null; + + function startEntryAnimation() { + if (entryCleanup) clearTimeout(entryCleanup); + cardEntering = true; + entryTransition = true; + requestAnimationFrame(() => { + cardEntering = false; + entryCleanup = setTimeout(() => { entryTransition = false; }, 400); + }); + } + + function cancelEntryAnimation() { + if (entryCleanup) { clearTimeout(entryCleanup); entryCleanup = null; } + cardEntering = false; + entryTransition = false; + } + + const activeTransform = $derived( + cardEntering + ? 'scale(0.95) translateY(13px)' + : `translateX(${offsetX}px) translateY(${offsetY}px) rotate(${rotation}deg)` + ); + + const activeTransition = $derived( + isDragging + ? 'none' + : (transitioning || entryTransition) + ? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)' + : 'none' + ); + function onPointerDown(e: PointerEvent) { + cancelEntryAnimation(); if (animating || !currentBook) return; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); startX = e.clientX; @@ -216,6 +252,8 @@ if (action === 'read_now') { goto(`/books/${book.slug}`); + } else { + startEntryAnimation(); } } @@ -493,8 +531,8 @@ bind:this={cardEl} class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10" style=" - transform: translateX({offsetX}px) translateY({offsetY}px) rotate({rotation}deg); - transition: {(transitioning && !isDragging) ? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)' : 'none'}; + transform: {activeTransform}; + transition: {activeTransition}; touch-action: none; " onpointerdown={onPointerDown}