add Ready to Listen feature: audio book shelf on home + /listen browse page
- getBooksWithAudioCount() in pocketbase.ts aggregates done audio_jobs, deduplicates by chapter per slug, caches 5 min - GET /api/audio/books endpoint - home page: readyToListen shelf with headphones badge, chapter count, Listen button, hideable - /listen page: full grid with search, sort (most narrated / A-Z / recent), empty state
This commit is contained in:
@@ -1165,6 +1165,60 @@ export async function getSlugsWithAudio(): Promise<Set<string>> {
|
|||||||
return new Set(jobs.map((j) => j.slug));
|
return new Set(jobs.map((j) => j.slug));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns books that have at least one completed audio chapter, sorted by
|
||||||
|
* number of narrated chapters descending.
|
||||||
|
* Cached for 5 minutes (same TTL as the catalogue audio badge).
|
||||||
|
*/
|
||||||
|
const AUDIO_BOOKS_CACHE_KEY = 'audio:books_with_count';
|
||||||
|
const AUDIO_BOOKS_CACHE_TTL = 5 * 60;
|
||||||
|
|
||||||
|
export interface AudioBookEntry {
|
||||||
|
book: Book;
|
||||||
|
audioChapters: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBooksWithAudioCount(limit = 100): Promise<AudioBookEntry[]> {
|
||||||
|
const cached = await cache.get<AudioBookEntry[]>(AUDIO_BOOKS_CACHE_KEY);
|
||||||
|
if (cached) return cached.slice(0, limit);
|
||||||
|
|
||||||
|
// Count done jobs per slug
|
||||||
|
const jobs = await listAll<AudioJob>('audio_jobs', 'status="done"', 'slug');
|
||||||
|
const countBySlug = new Map<string, number>();
|
||||||
|
for (const j of jobs) {
|
||||||
|
// audio_jobs can have multiple voice variants for the same chapter — deduplicate
|
||||||
|
// by chapter number so we count chapters, not voice variants.
|
||||||
|
// cache_key format: "slug/chapter/voice"
|
||||||
|
const slug = j.slug;
|
||||||
|
if (!countBySlug.has(slug)) countBySlug.set(slug, 0);
|
||||||
|
// We'll use a Set per slug after this loop instead
|
||||||
|
}
|
||||||
|
// Build slug → Set<chapter> to deduplicate voice variants
|
||||||
|
const chapsBySlug = new Map<string, Set<number>>();
|
||||||
|
for (const j of jobs) {
|
||||||
|
if (!chapsBySlug.has(j.slug)) chapsBySlug.set(j.slug, new Set());
|
||||||
|
chapsBySlug.get(j.slug)!.add(j.chapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const slugs = [...chapsBySlug.keys()];
|
||||||
|
if (slugs.length === 0) return [];
|
||||||
|
|
||||||
|
const books = await getBooksBySlugs(slugs);
|
||||||
|
const bookMap = new Map(books.map((b) => [b.slug, b]));
|
||||||
|
|
||||||
|
const entries: AudioBookEntry[] = [];
|
||||||
|
for (const [slug, chapters] of chapsBySlug) {
|
||||||
|
const book = bookMap.get(slug);
|
||||||
|
if (!book) continue;
|
||||||
|
entries.push({ book, audioChapters: chapters.size });
|
||||||
|
}
|
||||||
|
// Sort by most chapters narrated first
|
||||||
|
entries.sort((a, b) => b.audioChapters - a.audioChapters);
|
||||||
|
|
||||||
|
await cache.set(AUDIO_BOOKS_CACHE_KEY, entries, AUDIO_BOOKS_CACHE_TTL);
|
||||||
|
return entries.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Translation jobs ─────────────────────────────────────────────────────────
|
// ─── Translation jobs ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface TranslationJob {
|
export interface TranslationJob {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
getHomeStats,
|
getHomeStats,
|
||||||
getSubscriptionFeed,
|
getSubscriptionFeed,
|
||||||
getTrendingBooks,
|
getTrendingBooks,
|
||||||
getRecommendedBooks
|
getRecommendedBooks,
|
||||||
|
getBooksWithAudioCount
|
||||||
} from '$lib/server/pocketbase';
|
} from '$lib/server/pocketbase';
|
||||||
import { log } from '$lib/server/logger';
|
import { log } from '$lib/server/logger';
|
||||||
import type { Book, Progress } from '$lib/server/pocketbase';
|
import type { Book, Progress } from '$lib/server/pocketbase';
|
||||||
@@ -87,8 +88,8 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
|
const inProgressSlugs = new Set(continueReading.map((c) => c.book.slug));
|
||||||
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
|
const recentlyUpdated = recentBooks.filter((b) => !inProgressSlugs.has(b.slug)).slice(0, 6);
|
||||||
|
|
||||||
// Fetch trending, recommendations, and subscription feed in parallel
|
// Fetch trending, recommendations, subscription feed, and audio books in parallel
|
||||||
const [trendingBooks, recommendedBooks, subscriptionFeed] = await Promise.all([
|
const [trendingBooks, recommendedBooks, subscriptionFeed, audioBooks] = await Promise.all([
|
||||||
getTrendingBooks(8).catch(() => [] as Book[]),
|
getTrendingBooks(8).catch(() => [] as Book[]),
|
||||||
topGenres.length > 0
|
topGenres.length > 0
|
||||||
? getRecommendedBooks(topGenres, inProgressSlugs, 8).catch(() => [] as Book[])
|
? getRecommendedBooks(topGenres, inProgressSlugs, 8).catch(() => [] as Book[])
|
||||||
@@ -98,12 +99,18 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
log.error('home', 'failed to load subscription feed', { err: String(e) });
|
log.error('home', 'failed to load subscription feed', { err: String(e) });
|
||||||
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
|
return [] as Awaited<ReturnType<typeof getSubscriptionFeed>>;
|
||||||
})
|
})
|
||||||
: Promise.resolve([])
|
: Promise.resolve([]),
|
||||||
|
getBooksWithAudioCount(20).catch(() => [])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Strip books the user is already reading from trending (redundant)
|
// Strip books the user is already reading from trending (redundant)
|
||||||
const trendingFiltered = trendingBooks.filter((b) => !inProgressSlugs.has(b.slug));
|
const trendingFiltered = trendingBooks.filter((b) => !inProgressSlugs.has(b.slug));
|
||||||
|
|
||||||
|
// Strip already-reading books from audio shelf; cap at 8
|
||||||
|
const readyToListen = audioBooks
|
||||||
|
.filter((e) => !inProgressSlugs.has(e.book.slug))
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
continueInProgress,
|
continueInProgress,
|
||||||
continueCompleted,
|
continueCompleted,
|
||||||
@@ -111,6 +118,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
subscriptionFeed,
|
subscriptionFeed,
|
||||||
trendingBooks: trendingFiltered,
|
trendingBooks: trendingFiltered,
|
||||||
recommendedBooks,
|
recommendedBooks,
|
||||||
|
readyToListen,
|
||||||
topGenre: topGenres[0] ?? null,
|
topGenre: topGenres[0] ?? null,
|
||||||
stats: {
|
stats: {
|
||||||
...stats,
|
...stats,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
// ── Section visibility ────────────────────────────────────────────────────────
|
// ── Section visibility ────────────────────────────────────────────────────────
|
||||||
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read';
|
type SectionId = 'recently-updated' | 'browse-genre' | 'from-following' | 'trending' | 'because-you-read' | 'ready-to-listen';
|
||||||
const SECTIONS_KEY = 'home_sections_v1';
|
const SECTIONS_KEY = 'home_sections_v1';
|
||||||
|
|
||||||
function loadHidden(): Set<SectionId> {
|
function loadHidden(): Set<SectionId> {
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
'from-following': 'From Following',
|
'from-following': 'From Following',
|
||||||
'trending': 'Trending Now',
|
'trending': 'Trending Now',
|
||||||
'because-you-read': data.topGenre ? `Because you read ${data.topGenre}` : 'Recommendations',
|
'because-you-read': data.topGenre ? `Because you read ${data.topGenre}` : 'Recommendations',
|
||||||
|
'ready-to-listen': 'Ready to Listen',
|
||||||
});
|
});
|
||||||
|
|
||||||
const hiddenList = $derived(
|
const hiddenList = $derived(
|
||||||
@@ -307,6 +308,69 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/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 ─────────────────────────────────────────────────── -->
|
<!-- ── Genre discovery strip ─────────────────────────────────────────────────── -->
|
||||||
{#if !hidden.has('browse-genre')}
|
{#if !hidden.has('browse-genre')}
|
||||||
<section class="mb-10">
|
<section class="mb-10">
|
||||||
|
|||||||
17
ui/src/routes/api/audio/books/+server.ts
Normal file
17
ui/src/routes/api/audio/books/+server.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { getBooksWithAudioCount } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/audio/books
|
||||||
|
* Returns books that have at least one completed narrated chapter,
|
||||||
|
* sorted by number of narrated chapters descending.
|
||||||
|
* Cached 5 minutes at the CDN/proxy level.
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async () => {
|
||||||
|
const entries = await getBooksWithAudioCount(100).catch(() => []);
|
||||||
|
return json(
|
||||||
|
{ books: entries },
|
||||||
|
{ headers: { 'Cache-Control': 'public, max-age=300' } }
|
||||||
|
);
|
||||||
|
};
|
||||||
11
ui/src/routes/listen/+page.server.ts
Normal file
11
ui/src/routes/listen/+page.server.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getBooksWithAudioCount } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const sort = url.searchParams.get('sort') ?? 'chapters';
|
||||||
|
const q = url.searchParams.get('q') ?? '';
|
||||||
|
|
||||||
|
const audioBooks = await getBooksWithAudioCount(200).catch(() => []);
|
||||||
|
|
||||||
|
return { audioBooks, sort, q };
|
||||||
|
};
|
||||||
202
ui/src/routes/listen/+page.svelte
Normal file
202
ui/src/routes/listen/+page.svelte
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { audioStore } from '$lib/audio.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let q = $state(data.q);
|
||||||
|
let sort = $state(data.sort);
|
||||||
|
|
||||||
|
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 filtered = $derived.by(() => {
|
||||||
|
let list = data.audioBooks;
|
||||||
|
|
||||||
|
// text filter
|
||||||
|
if (q.trim()) {
|
||||||
|
const needle = q.trim().toLowerCase();
|
||||||
|
list = list.filter(
|
||||||
|
({ book }) =>
|
||||||
|
book.title?.toLowerCase().includes(needle) ||
|
||||||
|
book.author?.toLowerCase().includes(needle)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort
|
||||||
|
if (sort === 'title') {
|
||||||
|
list = [...list].sort((a, b) => (a.book.title ?? '').localeCompare(b.book.title ?? ''));
|
||||||
|
} else if (sort === 'recent') {
|
||||||
|
list = [...list].sort((a, b) => {
|
||||||
|
const da = a.book.updated ?? a.book.created ?? '';
|
||||||
|
const db = b.book.updated ?? b.book.created ?? '';
|
||||||
|
return db.localeCompare(da);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// default: 'chapters' — already sorted by getBooksWithAudioCount
|
||||||
|
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
function playChapter(slug: string, chapter: number) {
|
||||||
|
audioStore.autoStartChapter = chapter;
|
||||||
|
goto(`/books/${slug}/chapters/${chapter}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSortChange(value: string) {
|
||||||
|
sort = value;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (value !== 'chapters') params.set('sort', value);
|
||||||
|
if (q.trim()) params.set('q', q.trim());
|
||||||
|
const qs = params.toString();
|
||||||
|
goto(`/listen${qs ? `?${qs}` : ''}`, { replaceState: true, noScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (sort !== 'chapters') params.set('sort', sort);
|
||||||
|
if (q.trim()) params.set('q', q.trim());
|
||||||
|
const qs = params.toString();
|
||||||
|
goto(`/listen${qs ? `?${qs}` : ''}`, { replaceState: true, noScroll: true });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Narrated Books — LibNovel</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<svg class="w-5 h-5 text-(--color-brand)" 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>
|
||||||
|
<h1 class="text-xl font-bold text-(--color-text)">Narrated Books</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-(--color-muted)">Books with generated TTS audio ready to listen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3 mb-6">
|
||||||
|
<!-- Search -->
|
||||||
|
<form onsubmit={onSearch} class="flex-1 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
bind:value={q}
|
||||||
|
placeholder="Search by title or author…"
|
||||||
|
class="flex-1 min-w-0 px-3 py-2 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-text) placeholder:text-(--color-muted) text-sm focus:outline-none focus:border-(--color-brand)/60 transition-colors"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40 text-sm transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Sort -->
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
{#each [['chapters', 'Most narrated'], ['title', 'A–Z'], ['recent', 'Recent']] as [val, label]}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => onSortChange(val)}
|
||||||
|
class="px-3 py-2 rounded-lg text-xs font-medium transition-colors {sort === val
|
||||||
|
? 'bg-(--color-brand) text-(--color-surface)'
|
||||||
|
: 'bg-(--color-surface-2) border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:border-(--color-brand)/40'}"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results count -->
|
||||||
|
{#if filtered.length > 0}
|
||||||
|
<p class="text-xs text-(--color-muted) mb-4">{filtered.length} book{filtered.length !== 1 ? 's' : ''}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Grid -->
|
||||||
|
{#if filtered.length === 0}
|
||||||
|
<div class="text-center py-20 text-(--color-muted)">
|
||||||
|
{#if q.trim()}
|
||||||
|
<p class="text-base font-semibold text-(--color-text) mb-2">No results for "{q}"</p>
|
||||||
|
<p class="text-sm">Try a different search term.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-base font-semibold text-(--color-text) mb-2">No narrated books yet</p>
|
||||||
|
<p class="text-sm">Audio is generated as books are read. Check back soon.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||||
|
{#each filtered as { book, audioChapters }}
|
||||||
|
{@const genres = parseGenres(book.genres)}
|
||||||
|
<div 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">
|
||||||
|
<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-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}
|
||||||
|
<!-- 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 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-1">
|
||||||
|
{#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>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="px-2 pb-2 flex gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => playChapter(book.slug, 1)}
|
||||||
|
class="flex-1 flex items-center justify-center gap-1 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>
|
||||||
|
<a
|
||||||
|
href="/books/{book.slug}"
|
||||||
|
class="flex items-center justify-center px-2 py-1.5 rounded-md bg-(--color-surface-3) hover:bg-(--color-surface) border border-(--color-border) hover:border-(--color-brand)/40 text-(--color-muted) hover:text-(--color-text) transition-colors"
|
||||||
|
title="Book info"
|
||||||
|
aria-label="Book info"
|
||||||
|
>
|
||||||
|
<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 16h-1v-4h-1m1-4h.01M12 2a10 10 0 100 20A10 10 0 0012 2z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
Reference in New Issue
Block a user