Compare commits

...

5 Commits

Author SHA1 Message Date
root
dec11f0c01 fix: hero carousel — horizontal book spine stack instead of vertical overlap
All checks were successful
Release / Test backend (push) Successful in 49s
Release / Check ui (push) Successful in 1m55s
Release / Docker (push) Successful in 7m23s
Release / Gitea Release (push) Successful in 53s
2026-04-13 21:08:51 +05:00
root
0f1ded2269 feat: stacked card effect on home hero carousel (desktop sm+)
All checks were successful
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m54s
Release / Docker (push) Successful in 7m15s
Release / Gitea Release (push) Successful in 27s
2026-04-13 19:56:51 +05:00
root
2473a0213e feat: redesign discover page — desktop two-col, full-screen mobile, skeleton, streaming, keyboard shortcuts
Some checks failed
Release / Test backend (push) Successful in 47s
Release / Check ui (push) Successful in 1m48s
Release / Docker (push) Failing after 3m23s
Release / Gitea Release (push) Has been skipped
2026-04-13 17:11:09 +05:00
root
1064c784d4 fix: clamp hero carousel card height to cover aspect ratio, prevent text overflow
All checks were successful
Release / Test backend (push) Successful in 58s
Release / Check ui (push) Successful in 1m59s
Release / Docker (push) Successful in 6m24s
Release / Gitea Release (push) Successful in 23s
2026-04-13 10:32:51 +05:00
root
ed9eeb6262 feat: admin archive/delete UI for books (Danger Zone panel)
All checks were successful
Release / Test backend (push) Successful in 51s
Release / Check ui (push) Successful in 1m48s
Release / Docker (push) Successful in 6m54s
Release / Gitea Release (push) Successful in 28s
2026-04-12 22:49:15 +05:00
8 changed files with 586 additions and 347 deletions

View File

@@ -44,6 +44,7 @@ export interface Book {
source_url: string;
ranking: number;
meta_updated: string;
archived?: boolean;
}
export interface ChapterIdx {

View File

@@ -136,18 +136,24 @@
<!-- ── 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">
<div class="relative">
<!-- Card — swipe to navigate -->
<!-- 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"
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 -->
<!-- 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 aspect-[2/3] overflow-hidden block">
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}
@@ -162,19 +168,20 @@
{/if}
</a>
<!-- Info -->
<div class="flex flex-col justify-between p-5 sm:p-7 min-w-0 flex-1">
<div>
<!-- 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)">{heroBook.book.author}</p>
<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-2 max-w-prose">{heroBook.book.summary}</p>
<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">
<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>
@@ -197,23 +204,43 @@
</div>
</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}
<!-- 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}

View File

@@ -0,0 +1,34 @@
/**
* PATCH /api/admin/books/[slug]/archive
* PATCH /api/admin/books/[slug]/unarchive (action param: ?action=unarchive)
*
* Admin-only proxy. Soft-deletes (archives) or restores a book.
* Returns { slug, status: "archived" | "active" }.
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const PATCH: RequestHandler = async ({ params, url, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const { slug } = params;
const action = url.searchParams.get('action') === 'unarchive' ? 'unarchive' : 'archive';
let res: Response;
try {
res = await backendFetch(`/api/admin/books/${encodeURIComponent(slug)}/${action}`, {
method: 'PATCH'
});
} catch (e) {
log.error('admin/books/archive', 'backend proxy error', { slug, action, err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -0,0 +1,34 @@
/**
* DELETE /api/admin/books/[slug]/delete
*
* Admin-only proxy. Permanently removes a book and all its data:
* PocketBase records, MinIO objects, and the Meilisearch document.
* This operation is irreversible — use the archive endpoint for soft-deletion.
* Returns { slug, status: "deleted" }.
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { backendFetch } from '$lib/server/scraper';
export const DELETE: RequestHandler = async ({ params, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw error(403, 'Forbidden');
}
const { slug } = params;
let res: Response;
try {
res = await backendFetch(`/api/admin/books/${encodeURIComponent(slug)}`, {
method: 'DELETE'
});
} catch (e) {
log.error('admin/books/delete', 'backend proxy error', { slug, err: String(e) });
throw error(502, 'Could not reach backend');
}
const data = await res.json().catch(() => ({}));
return json(data, { status: res.status });
};

View File

@@ -95,7 +95,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
total_chapters: preview.meta.total_chapters,
source_url: preview.meta.source_url,
ranking: 0,
meta_updated: ''
meta_updated: '',
archived: false
};
return {

View File

@@ -616,6 +616,54 @@
}
}
// ── Admin: archive / delete ───────────────────────────────────────────────
let archiveStatus = $state<'idle' | 'busy' | 'done' | 'error'>('idle');
let deleteStatus = $state<'idle' | 'busy' | 'confirm' | 'done' | 'error'>('idle');
let bookArchived = $state(data.book?.archived ?? false);
async function toggleArchive() {
const slug = data.book?.slug;
if (!slug) return;
archiveStatus = 'busy';
const action = bookArchived ? 'unarchive' : 'archive';
try {
const res = await fetch(
`/api/admin/books/${encodeURIComponent(slug)}/archive?action=${action}`,
{ method: 'PATCH' }
);
if (res.ok) {
bookArchived = !bookArchived;
archiveStatus = 'done';
setTimeout(() => { archiveStatus = 'idle'; }, 3000);
} else {
archiveStatus = 'error';
}
} catch {
archiveStatus = 'error';
}
}
async function deleteBook() {
const slug = data.book?.slug;
if (!slug) return;
deleteStatus = 'busy';
try {
const res = await fetch(
`/api/admin/books/${encodeURIComponent(slug)}/delete`,
{ method: 'DELETE' }
);
if (res.ok) {
deleteStatus = 'done';
// Navigate away — book no longer exists
setTimeout(() => { goto('/admin/catalogue-tools'); }, 1500);
} else {
deleteStatus = 'error';
}
} catch {
deleteStatus = 'error';
}
}
// ── "More like this" ─────────────────────────────────────────────────────
interface SimilarBook { slug: string; title: string; cover: string | null; author: string | null }
let similarBooks = $state<SimilarBook[]>([]);
@@ -1525,10 +1573,83 @@
{/if}
{#if audioError}
<span class="text-xs text-(--color-muted)">{audioError}</span>
{/if}
</div>
{/if}
</div>
</div>
<hr class="border-(--color-border)" />
<!-- Archive / Delete -->
<div class="flex flex-col gap-2">
<p class="text-xs font-medium text-(--color-muted) uppercase tracking-wide">Danger Zone</p>
<!-- Archive / Unarchive -->
<div class="flex items-center gap-3 flex-wrap">
<button
onclick={toggleArchive}
disabled={archiveStatus === 'busy'}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium border transition-colors
{archiveStatus === 'busy'
? 'bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed border-(--color-border)'
: bookArchived
? 'bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 border-amber-500/30'
: 'bg-(--color-surface-3) text-(--color-text) hover:bg-(--color-surface-2) border-(--color-border)'}"
>
{#if archiveStatus === 'busy'}
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
{:else if bookArchived}
<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="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/></svg>
{:else}
<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="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/></svg>
{/if}
{bookArchived ? 'Unarchive' : 'Archive'}
</button>
{#if archiveStatus === 'done'}
<span class="text-xs text-green-400">{bookArchived ? 'Book archived — hidden from search.' : 'Book restored — visible again.'}</span>
{:else if archiveStatus === 'error'}
<span class="text-xs text-(--color-danger)">Action failed.</span>
{:else if bookArchived}
<span class="text-xs text-amber-400/70">This book is archived and hidden from all users.</span>
{/if}
</div>
<!-- Hard delete -->
<div class="flex items-center gap-3 flex-wrap">
{#if deleteStatus === 'confirm'}
<span class="text-xs text-(--color-danger)">This will permanently delete all chapters, audio, and cover. Cannot be undone.</span>
<button
onclick={deleteBook}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium bg-red-600/20 text-red-400 hover:bg-red-600/30 border border-red-600/30 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Confirm delete
</button>
<button onclick={() => { deleteStatus = 'idle'; }} class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors">Cancel</button>
{:else if deleteStatus === 'busy'}
<button disabled class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-muted) cursor-not-allowed border border-(--color-border)">
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
Deleting…
</button>
{:else if deleteStatus === 'done'}
<span class="text-xs text-green-400">Book deleted. Redirecting…</span>
{:else if deleteStatus === 'error'}
<button onclick={() => { deleteStatus = 'confirm'; }} class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-danger) hover:bg-(--color-surface-2) border border-(--color-border) 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Delete failed — retry?
</button>
{:else}
<button
onclick={() => { deleteStatus = 'confirm'; }}
class="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium bg-(--color-surface-3) text-(--color-danger) hover:bg-red-600/10 border border-(--color-border) 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Delete book
</button>
{/if}
</div>
</div>
</div>
{/if}
</div>
{/if}

View File

@@ -9,9 +9,12 @@ export const load: PageServerLoad = async ({ locals, url }) => {
try { prefs = JSON.parse(prefsParam) as DiscoveryPrefs; } catch { /* ignore */ }
}
const [books, votedBooks] = await Promise.all([
getBooksForDiscovery(locals.sessionId, locals.user?.id, prefs).catch(() => []),
getVotedBooks(locals.sessionId, locals.user?.id).catch(() => [])
]);
return { books, votedBooks };
// Return promises directly — SvelteKit streams them, so the page transitions
// immediately and content resolves async (skeleton shown while loading).
return {
streamed: {
books: getBooksForDiscovery(locals.sessionId, locals.user?.id, prefs).catch(() => []),
votedBooks: getVotedBooks(locals.sessionId, locals.user?.id).catch(() => []),
}
};
};

View File

@@ -60,8 +60,12 @@
try { return JSON.parse(genres) as string[]; } catch { return []; }
}
// Resolved books from streamed promise (populated in {#await} block via binding trick)
let resolvedBooks = $state<Book[]>([]);
let resolvedVotedBooks = $state<VotedBook[]>([]);
let deck = $derived.by(() => {
let books = data.books as Book[];
let books = resolvedBooks;
if (prefs.onboarded && prefs.genres.length > 0) {
const preferred = new Set(prefs.genres.map((g) => g.toLowerCase()));
const filtered = books.filter((b) => {
@@ -85,19 +89,14 @@
let offsetY = $state(0);
let transitioning = $state(false);
let showPreview = $state(false);
let voted = $state<{ slug: string; action: string } | null>(null); // last voted, for undo
let voted = $state<{ slug: string; action: string } | null>(null);
let showHistory = $state(false);
// svelte-ignore state_referenced_locally
let votedBooks = $state<VotedBook[]>(data.votedBooks ?? []);
// Keep in sync if server data refreshes
$effect(() => {
votedBooks = data.votedBooks ?? [];
});
let votedBooks = $state<VotedBook[]>([]);
// Sync when streamed data resolves
$effect(() => { if (resolvedVotedBooks.length) votedBooks = resolvedVotedBooks; });
async function undoVote(slug: string) {
// Optimistic update
votedBooks = votedBooks.filter((v) => v.slug !== slug);
await fetch(`/api/discover/vote?slug=${encodeURIComponent(slug)}`, { method: 'DELETE' });
}
@@ -107,11 +106,19 @@
let currentBook = $derived(deck[idx] as Book | undefined);
let nextBook = $derived(deck[idx + 1] as Book | undefined);
let nextNextBook = $derived(deck[idx + 2] as Book | undefined);
let deckEmpty = $derived(!currentBook);
let deckEmpty = $derived(resolvedBooks.length > 0 && !currentBook);
let loading = $derived(resolvedBooks.length === 0);
let totalRemaining = $derived(Math.max(0, deck.length - idx));
// Which direction/indicator to show
let indicator = $derived.by((): 'like' | 'skip' | 'read_now' | 'nope' | null => {
// Preload next card image
$effect(() => {
if (!browser || !nextBook?.cover) return;
const img = new Image();
img.src = nextBook.cover;
});
// Which direction/indicator to show (no NOPE — only 3 actions)
let indicator = $derived.by((): 'like' | 'skip' | 'read_now' | null => {
if (!isDragging) return null;
const ax = Math.abs(offsetX), ay = Math.abs(offsetY);
const threshold = 20;
@@ -120,7 +127,6 @@
if (offsetX < -threshold) return 'skip';
} else {
if (offsetY < -threshold) return 'read_now';
if (offsetY > threshold) return 'nope';
}
return null;
});
@@ -134,7 +140,7 @@
let cardEl = $state<HTMLDivElement | null>(null);
// ── Card entry animation (prevents pop-to-full-size after swipe) ─────────────
// ── Card entry animation ──────────────────────────────────────────────────
let cardEntering = $state(false);
let entryTransition = $state(false);
let entryCleanup: ReturnType<typeof setTimeout> | null = null;
@@ -201,14 +207,12 @@
if (ax > ay && ax > THRESHOLD_X) {
await doAction(offsetX > 0 ? 'like' : 'skip');
} else if (ay > ax && ay > THRESHOLD_Y) {
await doAction(offsetY < 0 ? 'read_now' : 'nope');
} else if (ay > ax && ay > THRESHOLD_Y && offsetY < 0) {
await doAction('read_now');
} else if (!hasMoved) {
// Tap without drag → preview
showPreview = true;
offsetX = 0; offsetY = 0;
} else {
// Snap back
transitioning = true;
offsetX = 0; offsetY = 0;
await delay(320);
@@ -218,13 +222,12 @@
function delay(ms: number) { return new Promise<void>((r) => setTimeout(r, ms)); }
type VoteAction = 'like' | 'skip' | 'nope' | 'read_now';
type VoteAction = 'like' | 'skip' | 'read_now';
const flyTargets: Record<VoteAction, { x: number; y: number }> = {
like: { x: 1300, y: -80 },
skip: { x: -1300, y: -80 },
read_now: { x: 30, y: -1300 },
nope: { x: 0, y: 1300 }
};
async function doAction(action: VoteAction) {
@@ -232,15 +235,12 @@
animating = true;
const book = currentBook;
// Record vote (fire and forget)
fetch('/api/discover/vote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: book.slug, action })
});
// Optimistically add/update the history list so the drawer shows it immediately.
// If this slug was already voted (e.g. swiped twice via undo+re-swipe), replace it.
const existing = votedBooks.findIndex((v) => v.slug === book.slug);
const entry: VotedBook = { slug: book.slug, action, votedAt: new Date().toISOString(), book };
if (existing !== -1) {
@@ -249,7 +249,6 @@
votedBooks = [entry, ...votedBooks];
}
// Fly out
transitioning = true;
const target = flyTargets[action];
offsetX = target.x;
@@ -257,7 +256,6 @@
await delay(360);
// Advance
voted = { slug: book.slug, action };
idx++;
transitioning = false;
@@ -279,6 +277,21 @@
idx = 0;
window.location.reload();
}
// ── Keyboard shortcuts (desktop) ─────────────────────────────────────────
$effect(() => {
if (!browser) return;
function onKey(e: KeyboardEvent) {
if (showOnboarding || showPreview || showHistory) return;
if (animating || !currentBook) return;
if (e.key === 'ArrowRight') { e.preventDefault(); doAction('like'); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); doAction('skip'); }
else if (e.key === 'ArrowUp') { e.preventDefault(); doAction('read_now'); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); showPreview = true; }
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
</script>
<!-- ── Onboarding modal ───────────────────────────────────────────────────────── -->
@@ -290,8 +303,6 @@
<h2 class="text-xl font-bold text-(--color-text) mb-1">What do you like to read?</h2>
<p class="text-sm text-(--color-muted)">We'll show you books you'll actually enjoy. Skip to see everything.</p>
</div>
<!-- Genre pills -->
<div class="mb-5">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest mb-3">Genres</p>
<div class="flex flex-wrap gap-2">
@@ -309,14 +320,10 @@
{tempGenres.includes(genre)
? 'bg-(--color-brand) text-(--color-surface) border-(--color-brand)'
: 'bg-(--color-surface-3) text-(--color-muted) border-transparent hover:border-(--color-border) hover:text-(--color-text)'}"
>
{genre}
</button>
>{genre}</button>
{/each}
</div>
</div>
<!-- Status -->
<div class="mb-6">
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-widest mb-3">Status</p>
<div class="flex gap-2">
@@ -328,26 +335,17 @@
{tempStatus === s
? 'bg-(--color-brand) text-(--color-surface) border-(--color-brand)'
: 'bg-(--color-surface-3) text-(--color-muted) border-transparent hover:text-(--color-text)'}"
>
{s === 'either' ? 'Either' : s.charAt(0).toUpperCase() + s.slice(1)}
</button>
>{s === 'either' ? 'Either' : s.charAt(0).toUpperCase() + s.slice(1)}</button>
{/each}
</div>
</div>
<div class="flex gap-3">
<button
type="button"
onclick={() => finishOnboarding(true)}
class="flex-1 py-2.5 rounded-xl text-sm font-medium text-(--color-muted) hover:text-(--color-text) transition-colors"
>
<button type="button" onclick={() => finishOnboarding(true)}
class="flex-1 py-2.5 rounded-xl text-sm font-medium text-(--color-muted) hover:text-(--color-text) transition-colors">
Skip
</button>
<button
type="button"
onclick={() => finishOnboarding(false)}
class="flex-[2] py-2.5 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) transition-colors"
>
<button type="button" onclick={() => finishOnboarding(false)}
class="flex-[2] py-2.5 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) hover:bg-(--color-brand-dim) transition-colors">
Start Discovering
</button>
</div>
@@ -358,7 +356,7 @@
<!-- ── Preview modal ───────────────────────────────────────────────────────────── -->
{#if showPreview && currentBook}
{@const previewBook = currentBook!}
{@const previewBook = currentBook}
<div
class="fixed inset-0 z-40 flex items-end sm:items-center justify-center p-4"
role="presentation"
@@ -368,13 +366,10 @@
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
class="relative w-full max-w-md bg-(--color-surface-2) rounded-2xl border border-(--color-border) shadow-2xl overflow-hidden"
role="dialog"
aria-modal="true"
tabindex="-1"
role="dialog" aria-modal="true" tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<!-- Cover strip -->
<div class="relative h-40 overflow-hidden">
{#if previewBook.cover}
<img src={previewBook.cover} alt={previewBook.title} class="w-full h-full object-cover object-top" />
@@ -387,7 +382,6 @@
</div>
{/if}
</div>
<div class="p-5">
<h3 class="font-bold text-(--color-text) text-lg leading-snug mb-1">{previewBook.title}</h3>
{#if previewBook.author}
@@ -407,29 +401,13 @@
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{previewBook.total_chapters} ch.</span>
{/if}
</div>
<div class="flex gap-2">
<button
type="button"
onclick={() => { showPreview = false; doAction('skip'); }}
class="flex-1 py-2.5 rounded-xl text-sm font-medium bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-danger) transition-colors"
>
Skip
</button>
<button
type="button"
onclick={() => { showPreview = false; doAction('read_now'); }}
class="flex-1 py-2.5 rounded-xl text-sm font-bold bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors"
>
Read Now
</button>
<button
type="button"
onclick={() => { showPreview = false; doAction('like'); }}
class="flex-1 py-2.5 rounded-xl text-sm font-bold bg-(--color-success)/20 text-(--color-success) hover:bg-(--color-success)/30 transition-colors"
>
Add ♥
</button>
<button type="button" onclick={() => { showPreview = false; doAction('skip'); }}
class="flex-1 py-2.5 rounded-xl text-sm font-medium bg-(--color-surface-3) text-(--color-muted) hover:text-(--color-danger) transition-colors">Skip</button>
<button type="button" onclick={() => { showPreview = false; doAction('read_now'); }}
class="flex-1 py-2.5 rounded-xl text-sm font-bold bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors">Read Now</button>
<button type="button" onclick={() => { showPreview = false; doAction('like'); }}
class="flex-1 py-2.5 rounded-xl text-sm font-bold bg-(--color-success)/20 text-(--color-success) hover:bg-(--color-success)/30 transition-colors">Add ♥</button>
</div>
</div>
</div>
@@ -447,20 +425,14 @@
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div
class="relative w-full max-w-md bg-(--color-surface-2) rounded-2xl border border-(--color-border) shadow-2xl overflow-hidden max-h-[80vh] flex flex-col"
role="dialog"
aria-modal="true"
tabindex="-1"
role="dialog" aria-modal="true" tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<div class="flex items-center justify-between p-5 border-b border-(--color-border) flex-shrink-0">
<h3 class="font-bold text-(--color-text)">History {#if votedBooks.length}<span class="text-(--color-muted) font-normal text-sm">({votedBooks.length})</span>{/if}</h3>
<button
type="button"
onclick={() => (showHistory = false)}
aria-label="Close history"
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
>
<button type="button" onclick={() => (showHistory = false)} aria-label="Close history"
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
@@ -472,7 +444,7 @@
{:else}
{#each votedBooks as v (v.slug)}
{@const actionColor = v.action === 'like' ? 'text-green-400' : v.action === 'read_now' ? 'text-blue-400' : 'text-(--color-muted)'}
{@const actionLabel = v.action === 'like' ? 'Liked' : v.action === 'read_now' ? 'Read Now' : v.action === 'skip' ? 'Skipped' : 'Noped'}
{@const actionLabel = v.action === 'like' ? 'Liked' : v.action === 'read_now' ? 'Read Now' : 'Skipped'}
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-xl p-3">
{#if v.book?.cover}
<img src={v.book.cover} alt="" class="w-10 h-14 rounded-md object-cover flex-shrink-0" />
@@ -488,24 +460,17 @@
{/if}
<span class="text-xs font-medium {actionColor}">{actionLabel}</span>
</div>
<button
type="button"
onclick={() => undoVote(v.slug)}
title="Undo"
<button type="button" onclick={() => undoVote(v.slug)} title="Undo"
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-danger) hover:bg-(--color-danger)/10 transition-colors flex-shrink-0"
aria-label="Undo vote for {v.book?.title ?? v.slug}"
>
aria-label="Undo vote for {v.book?.title ?? v.slug}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"/>
</svg>
</button>
</div>
{/each}
<button
type="button"
onclick={resetDeck}
class="w-full py-2 rounded-xl text-sm text-(--color-muted) hover:text-(--color-text) transition-colors mt-2"
>
<button type="button" onclick={resetDeck}
class="w-full py-2 rounded-xl text-sm text-(--color-muted) hover:text-(--color-text) transition-colors mt-2">
Clear all history
</button>
{/if}
@@ -514,247 +479,300 @@
</div>
{/if}
<!-- ── Main layout ────────────────────────────────────────────────────────────── -->
<div class="min-h-screen bg-(--color-surface) flex flex-col items-center px-3 pt-6 pb-6 select-none">
<!-- ── Await streamed data ─────────────────────────────────────────────────────── -->
{#await Promise.all([data.streamed.books, data.streamed.votedBooks]) then [books, vb]}
<!-- Silently populate reactive state once data resolves -->
{@const _ = (() => { resolvedBooks = books as Book[]; resolvedVotedBooks = vb as VotedBook[]; return ''; })()}
{/await}
<!-- Header -->
<div class="w-full max-w-sm flex items-center justify-between mb-4">
<div>
<h1 class="text-xl font-bold text-(--color-text)">Discover</h1>
{#if !deckEmpty}
<p class="text-xs text-(--color-muted)">{totalRemaining} books left</p>
{/if}
</div>
<div class="flex items-center gap-1">
<!-- History button -->
<button
type="button"
onclick={() => (showHistory = true)}
title="History"
class="relative w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{#if votedBooks.length}
<span class="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[9px] font-bold flex items-center justify-center leading-none">
{votedBooks.length > 9 ? '9+' : votedBooks.length}
</span>
{/if}
</button>
<!-- Preferences button -->
<button
type="button"
onclick={() => { showOnboarding = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
title="Preferences"
class="w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
</svg>
</button>
</div>
</div>
<!-- ── Page layout ────────────────────────────────────────────────────────────── -->
<div class="select-none -mx-4 -my-8 lg:min-h-[calc(100svh-3.5rem)]
lg:grid lg:grid-cols-[1fr_380px] xl:grid-cols-[1fr_420px]">
{#if deckEmpty}
<!-- Empty state -->
<div class="flex-1 flex flex-col items-center justify-center gap-6 text-center max-w-xs">
<div class="w-20 h-20 rounded-full bg-(--color-surface-2) flex items-center justify-center text-4xl">
📚
</div>
<!-- ── Left: card area ──────────────────────────────────────────────────── -->
<div class="relative bg-(--color-surface) flex flex-col items-center justify-center
px-4 pt-6 pb-4 lg:px-8 lg:pt-8 lg:pb-8
min-h-[calc(100svh-3.5rem)] lg:border-r lg:border-(--color-border)">
<!-- Header row -->
<div class="w-full max-w-sm lg:max-w-none flex items-center justify-between mb-4 shrink-0">
<div>
<h2 class="text-lg font-bold text-(--color-text) mb-2">All caught up!</h2>
<p class="text-sm text-(--color-muted)">
You've seen all available books.
{#if prefs.genres.length > 0}
Try adjusting your preferences to see more.
{:else}
Check your library for books you liked.
{/if}
</p>
<h1 class="text-xl font-bold text-(--color-text)">Discover</h1>
{#if !loading && !deckEmpty}
<p class="text-xs text-(--color-muted)">{totalRemaining} books left</p>
{/if}
</div>
<div class="flex flex-col gap-2 w-full">
<a href="/books" class="py-2.5 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) text-center hover:bg-(--color-brand-dim) transition-colors">
My Library
</a>
<button
type="button"
onclick={resetDeck}
class="py-2.5 rounded-xl text-sm font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors"
>
Start over
<div class="flex items-center gap-1">
<button type="button" onclick={() => (showHistory = true)} title="History"
class="relative w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{#if votedBooks.length}
<span class="absolute -top-0.5 -right-0.5 w-4 h-4 rounded-full bg-(--color-brand) text-(--color-surface) text-[9px] font-bold flex items-center justify-center leading-none">
{votedBooks.length > 9 ? '9+' : votedBooks.length}
</span>
{/if}
</button>
<button type="button"
onclick={() => { showOnboarding = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
title="Preferences"
class="w-9 h-9 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
</svg>
</button>
</div>
</div>
{:else}
{@const book = currentBook!}
<!-- Card stack — fills available width, taller ratio -->
<div class="w-full max-w-sm relative" style="aspect-ratio: 3/4.6;">
<!-- Back card 2 -->
{#if nextNextBook}
<div
class="absolute inset-0 rounded-2xl overflow-hidden shadow-lg"
style="transform: scale(0.90) translateY(26px); z-index: 1; transition: transform 0.35s ease;"
>
{#if nextNextBook.cover}
<img src={nextNextBook.cover} alt="" class="w-full h-full object-cover" />
{:else}
<div class="w-full h-full bg-(--color-surface-3)"></div>
{/if}
{#if loading}
<!-- ── Skeleton ──────────────────────────────────────────────────── -->
<div class="w-full max-w-sm lg:max-w-none flex-1 flex flex-col gap-4">
<div class="flex-1 rounded-2xl bg-(--color-surface-2) animate-pulse" style="min-height: 340px;"></div>
<div class="flex gap-3">
<div class="flex-1 h-14 rounded-2xl bg-(--color-surface-2) animate-pulse"></div>
<div class="flex-[1.4] h-14 rounded-2xl bg-(--color-surface-2) animate-pulse"></div>
<div class="flex-1 h-14 rounded-2xl bg-(--color-surface-2) animate-pulse"></div>
</div>
{/if}
</div>
<!-- Back card 1 -->
{#if nextBook}
<div
class="absolute inset-0 rounded-2xl overflow-hidden shadow-xl"
style="transform: scale(0.95) translateY(13px); z-index: 2; transition: transform 0.35s ease;"
>
{#if nextBook.cover}
<img src={nextBook.cover} alt="" class="w-full h-full object-cover" />
{:else}
<div class="w-full h-full bg-(--color-surface-3)"></div>
{/if}
{:else if deckEmpty}
<!-- ── Empty state ───────────────────────────────────────────────── -->
<div class="flex-1 flex flex-col items-center justify-center gap-6 text-center max-w-xs">
<div class="w-20 h-20 rounded-full bg-(--color-surface-2) flex items-center justify-center text-4xl">📚</div>
<div>
<h2 class="text-lg font-bold text-(--color-text) mb-2">All caught up!</h2>
<p class="text-sm text-(--color-muted)">
You've seen all available books.
{#if prefs.genres.length > 0}Try adjusting your preferences to see more.
{:else}Check your library for books you liked.{/if}
</p>
</div>
{/if}
<div class="flex flex-col gap-2 w-full">
<a href="/books" class="py-2.5 rounded-xl text-sm font-bold bg-(--color-brand) text-(--color-surface) text-center hover:bg-(--color-brand-dim) transition-colors">
My Library
</a>
<button type="button" onclick={resetDeck}
class="py-2.5 rounded-xl text-sm font-medium bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) transition-colors">
Start over
</button>
</div>
</div>
<!-- Active card -->
{#if currentBook}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={cardEl}
role="none"
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
style="
transform: {activeTransform};
transition: {activeTransition};
touch-action: none;
"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<!-- Cover image -->
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover pointer-events-none" draggable="false" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center pointer-events-none">
<svg class="w-16 h-16 text-(--color-border)" 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>
{:else}
{@const book = currentBook!}
<!-- ── Card stack ───────────────────────────────────────────────── -->
<div class="w-full max-w-sm lg:max-w-none flex-1 relative" style="min-height: 300px;">
<!-- Back card 2 -->
{#if nextNextBook}
<div class="absolute inset-0 rounded-2xl overflow-hidden shadow-lg"
style="transform: scale(0.90) translateY(26px); z-index: 1; transition: transform 0.35s ease;">
{#if nextNextBook.cover}
<img src={nextNextBook.cover} alt="" class="w-full h-full object-cover" />
{:else}
<div class="w-full h-full bg-(--color-surface-3)"></div>
{/if}
</div>
{/if}
<!-- Bottom gradient + info -->
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent pointer-events-none"></div>
<div class="absolute bottom-0 left-0 right-0 p-5 pointer-events-none">
<h2 class="text-white font-bold text-2xl leading-snug line-clamp-2 mb-1">{book.title}</h2>
{#if book.author}
<p class="text-white/70 text-sm mb-2">{book.author}</p>
{/if}
<div class="flex flex-wrap gap-1.5 items-center">
{#each parseBookGenres(book.genres).slice(0, 2) as genre}
<span class="text-xs bg-white/15 text-white/90 px-2 py-0.5 rounded-full backdrop-blur-sm">{genre}</span>
{/each}
{#if book.status}
<span class="text-xs bg-white/10 text-white/60 px-2 py-0.5 rounded-full">{book.status}</span>
{/if}
{#if book.total_chapters}
<span class="text-xs text-white/50 ml-auto">{book.total_chapters} ch.</span>
<!-- Back card 1 -->
{#if nextBook}
<div class="absolute inset-0 rounded-2xl overflow-hidden shadow-xl"
style="transform: scale(0.95) translateY(13px); z-index: 2; transition: transform 0.35s ease;">
{#if nextBook.cover}
<img src={nextBook.cover} alt="" class="w-full h-full object-cover" />
{:else}
<div class="w-full h-full bg-(--color-surface-3)"></div>
{/if}
</div>
</div>
{/if}
<!-- LIKE indicator (right swipe) -->
<!-- Active card -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute top-8 right-6 px-4 py-2 rounded-xl border-[3px] border-green-400 rotate-[-15deg] pointer-events-none bg-green-400/10"
style="opacity: {indicator === 'like' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
bind:this={cardEl}
role="none"
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
style="transform: {activeTransform}; transition: {activeTransition}; touch-action: none;"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<span class="text-green-400 font-black text-2xl tracking-widest">LIKE</span>
</div>
{#if book.cover}
<img src={book.cover} alt={book.title} class="w-full h-full object-cover pointer-events-none" draggable="false" />
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center pointer-events-none">
<svg class="w-16 h-16 text-(--color-border)" 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}
<!-- SKIP indicator (left swipe) -->
<div
class="absolute top-8 left-6 px-4 py-2 rounded-xl border-[3px] border-red-400 rotate-[15deg] pointer-events-none bg-red-400/10"
style="opacity: {indicator === 'skip' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
>
<span class="text-red-400 font-black text-2xl tracking-widest">SKIP</span>
</div>
<!-- Bottom gradient + title/genres (mobile only — desktop shows in panel) -->
<div class="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent pointer-events-none lg:hidden"></div>
<div class="absolute bottom-0 left-0 right-0 p-5 pointer-events-none lg:hidden">
<h2 class="text-white font-bold text-2xl leading-snug line-clamp-2 mb-1">{book.title}</h2>
{#if book.author}<p class="text-white/70 text-sm mb-2">{book.author}</p>{/if}
<div class="flex flex-wrap gap-1.5 items-center">
{#each parseBookGenres(book.genres).slice(0, 2) as genre}
<span class="text-xs bg-white/15 text-white/90 px-2 py-0.5 rounded-full backdrop-blur-sm">{genre}</span>
{/each}
{#if book.status}
<span class="text-xs bg-white/10 text-white/60 px-2 py-0.5 rounded-full">{book.status}</span>
{/if}
{#if book.total_chapters}
<span class="text-xs text-white/50 ml-auto">{book.total_chapters} ch.</span>
{/if}
</div>
</div>
<!-- READ NOW indicator (swipe up) -->
<div
class="absolute top-8 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl border-[3px] border-blue-400 pointer-events-none bg-blue-400/10"
style="opacity: {indicator === 'read_now' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
>
<span class="text-blue-400 font-black text-2xl tracking-widest">READ NOW</span>
</div>
<!-- NOPE indicator (swipe down) -->
<div
class="absolute bottom-32 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl border-[3px] border-zinc-400 pointer-events-none bg-black/20"
style="opacity: {indicator === 'nope' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
>
<span class="text-zinc-300 font-black text-2xl tracking-widest">NOPE</span>
<!-- Swipe indicators -->
<div class="absolute top-8 right-6 px-4 py-2 rounded-xl border-[3px] border-green-400 rotate-[-15deg] pointer-events-none bg-green-400/10"
style="opacity: {indicator === 'like' ? indicatorOpacity : 0}; transition: opacity 0.1s;">
<span class="text-green-400 font-black text-2xl tracking-widest">LIKE</span>
</div>
<div class="absolute top-8 left-6 px-4 py-2 rounded-xl border-[3px] border-red-400 rotate-[15deg] pointer-events-none bg-red-400/10"
style="opacity: {indicator === 'skip' ? indicatorOpacity : 0}; transition: opacity 0.1s;">
<span class="text-red-400 font-black text-2xl tracking-widest">SKIP</span>
</div>
<div class="absolute top-8 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl border-[3px] border-blue-400 pointer-events-none bg-blue-400/10"
style="opacity: {indicator === 'read_now' ? indicatorOpacity : 0}; transition: opacity 0.1s;">
<span class="text-blue-400 font-black text-2xl tracking-widest">READ NOW</span>
</div>
</div>
</div>
{/if}
</div>
<!-- Action buttons — 3 prominent labeled buttons -->
<div class="w-full max-w-sm flex items-stretch gap-3 mt-5">
<!-- Skip -->
<button
type="button"
onclick={() => doAction('skip')}
disabled={animating}
title="Skip"
class="flex-1 flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
bg-red-500/15 border border-red-500/30 text-red-400
hover:bg-red-500/25 hover:border-red-500/50
active:scale-95 transition-all disabled:opacity-40"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span class="text-xs font-bold tracking-wide">Skip</span>
</button>
<!-- Action buttons -->
<div class="w-full max-w-sm lg:max-w-none flex items-stretch gap-3 mt-4 shrink-0">
<button type="button" onclick={() => doAction('skip')} disabled={animating} title="Skip (←)"
class="flex-1 flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
bg-red-500/15 border border-red-500/30 text-red-400
hover:bg-red-500/25 hover:border-red-500/50 active:scale-95 transition-all disabled:opacity-40">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
</svg>
<span class="text-xs font-bold tracking-wide">Skip</span>
</button>
<button type="button" onclick={() => doAction('read_now')} disabled={animating} title="Read Now (↑)"
class="flex-[1.4] flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
bg-blue-500 text-white hover:bg-blue-400 active:scale-95 transition-all disabled:opacity-40 shadow-lg shadow-blue-500/25">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
<span class="text-xs font-bold tracking-wide">Read Now</span>
</button>
<button type="button" onclick={() => doAction('like')} disabled={animating} title="Like (→)"
class="flex-1 flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
bg-green-500/15 border border-green-500/30 text-green-400
hover:bg-green-500/25 hover:border-green-500/50 active:scale-95 transition-all disabled:opacity-40">
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
<span class="text-xs font-bold tracking-wide">Like</span>
</button>
</div>
<!-- Read Now — center, most prominent -->
<button
type="button"
onclick={() => doAction('read_now')}
disabled={animating}
title="Read Now"
class="flex-[1.4] flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
bg-blue-500 text-white
hover:bg-blue-400
active:scale-95 transition-all disabled:opacity-40 shadow-lg shadow-blue-500/25"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
<span class="text-xs font-bold tracking-wide">Read Now</span>
</button>
<!-- Keyboard hint (desktop only) -->
<p class="hidden lg:block text-xs text-(--color-muted)/40 mt-2 shrink-0">← Skip · ↑ Read now · → Like · Space for details</p>
{/if}
</div>
<!-- ── Right: book details panel (desktop only) ──────────────────────────── -->
<div class="hidden lg:flex flex-col bg-(--color-surface-2) border-l border-(--color-border)
overflow-y-auto" style="max-height: calc(100svh - 3.5rem);">
{#if loading}
<!-- Skeleton -->
<div class="p-8 flex flex-col gap-4">
<div class="h-6 rounded-lg bg-(--color-surface-3) animate-pulse w-3/4"></div>
<div class="h-4 rounded-lg bg-(--color-surface-3) animate-pulse w-1/2"></div>
<div class="h-32 rounded-xl bg-(--color-surface-3) animate-pulse"></div>
<div class="flex gap-2">
<div class="h-6 rounded-full bg-(--color-surface-3) animate-pulse w-16"></div>
<div class="h-6 rounded-full bg-(--color-surface-3) animate-pulse w-20"></div>
<div class="h-6 rounded-full bg-(--color-surface-3) animate-pulse w-14"></div>
</div>
</div>
{:else if !deckEmpty && currentBook}
{@const book = currentBook}
<!-- Book details -->
<div class="p-8 flex flex-col gap-6 flex-1">
<!-- Title + author -->
<div>
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-2">Now showing</p>
<h2 class="text-2xl font-bold text-(--color-text) leading-snug mb-1">{book.title}</h2>
{#if book.author}
<p class="text-sm text-(--color-muted)">{book.author}</p>
{/if}
</div>
<!-- Metadata pills -->
<div class="flex flex-wrap gap-2">
{#each parseBookGenres(book.genres).slice(0, 5) as genre}
<span class="text-xs px-2.5 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
{/each}
{#if book.status}
<span class="text-xs px-2.5 py-1 rounded-full bg-(--color-surface-3) text-(--color-text) font-medium">{book.status}</span>
{/if}
</div>
<!-- Stats row -->
<div class="flex items-center gap-4 text-sm text-(--color-muted)">
{#if book.total_chapters}
<span class="flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{book.total_chapters} chapters
</span>
{/if}
<span class="text-xs text-(--color-muted)/40">{idx + 1} of {deck.length}</span>
</div>
<!-- Summary -->
{#if book.summary}
<div>
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider mb-2">Summary</p>
<p class="text-sm text-(--color-muted) leading-relaxed">{book.summary}</p>
</div>
{/if}
<!-- Action buttons (duplicated for desktop convenience) -->
<div class="mt-auto flex flex-col gap-2.5 pt-4 border-t border-(--color-border)">
<button type="button" onclick={() => doAction('read_now')} disabled={animating}
class="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-blue-500 text-white font-bold text-sm hover:bg-blue-400 active:scale-95 transition-all disabled:opacity-40 shadow-lg shadow-blue-500/20">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
Read Now
</button>
<div class="flex gap-2">
<button type="button" onclick={() => doAction('skip')} disabled={animating}
class="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-xl bg-red-500/15 border border-red-500/30 text-red-400 text-sm font-semibold hover:bg-red-500/25 active:scale-95 transition-all disabled:opacity-40">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
</svg>
Skip
</button>
<button type="button" onclick={() => doAction('like')} disabled={animating}
class="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-xl bg-green-500/15 border border-green-500/30 text-green-400 text-sm font-semibold hover:bg-green-500/25 active:scale-95 transition-all disabled:opacity-40">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
Like
</button>
</div>
<button type="button" onclick={() => { showPreview = true; }}
class="w-full py-2 rounded-xl text-xs text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors">
More details
</button>
</div>
</div>
{:else if deckEmpty}
<div class="flex-1 flex flex-col items-center justify-center gap-4 text-center p-8 text-(--color-muted)">
<span class="text-4xl">📚</span>
<p class="text-sm">All caught up!</p>
</div>
{/if}
</div>
<!-- Like -->
<button
type="button"
onclick={() => doAction('like')}
disabled={animating}
title="Add to Library"
class="flex-1 flex flex-col items-center justify-center gap-1.5 py-3.5 rounded-2xl
bg-green-500/15 border border-green-500/30 text-green-400
hover:bg-green-500/25 hover:border-green-500/50
active:scale-95 transition-all disabled:opacity-40"
>
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
</svg>
<span class="text-xs font-bold tracking-wide">Like</span>
</button>
</div>
{/if}
</div>