|
|
|
|
@@ -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>
|
|
|
|
|
|