diff --git a/ui/src/routes/discover/+page.server.ts b/ui/src/routes/discover/+page.server.ts index b5f87e6..c8d8a32 100644 --- a/ui/src/routes/discover/+page.server.ts +++ b/ui/src/routes/discover/+page.server.ts @@ -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(() => []), + } + }; }; diff --git a/ui/src/routes/discover/+page.svelte b/ui/src/routes/discover/+page.svelte index da22ca3..c344c95 100644 --- a/ui/src/routes/discover/+page.svelte +++ b/ui/src/routes/discover/+page.svelte @@ -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([]); + let resolvedVotedBooks = $state([]); + 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(data.votedBooks ?? []); - - // Keep in sync if server data refreshes - $effect(() => { - votedBooks = data.votedBooks ?? []; - }); + let votedBooks = $state([]); + // 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(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 | 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((r) => setTimeout(r, ms)); } - type VoteAction = 'like' | 'skip' | 'nope' | 'read_now'; + type VoteAction = 'like' | 'skip' | 'read_now'; const flyTargets: Record = { 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); + }); @@ -290,8 +303,6 @@

What do you like to read?

We'll show you books you'll actually enjoy. Skip to see everything.

- -

Genres

@@ -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} - + >{genre} {/each}
- -

Status

@@ -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)} - + >{s === 'either' ? 'Either' : s.charAt(0).toUpperCase() + s.slice(1)} {/each}
-
- -
@@ -358,7 +356,7 @@ {#if showPreview && currentBook} -{@const previewBook = currentBook!} +{@const previewBook = currentBook} -

{previewBook.title}

{#if previewBook.author} @@ -407,29 +401,13 @@ {previewBook.total_chapters} ch. {/if}
-
- - - + + +
@@ -447,20 +425,14 @@
{/if} - -
+ +{#await Promise.all([data.streamed.books, data.streamed.votedBooks]) then [books, vb]} + + {@const _ = (() => { resolvedBooks = books as Book[]; resolvedVotedBooks = vb as VotedBook[]; return ''; })()} +{/await} - -
-
-

Discover

- {#if !deckEmpty} -

{totalRemaining} books left

- {/if} -
-
- - - - -
-
+ +
- {#if deckEmpty} - -
-
- 📚 -
+ +
+ + +
-

All caught up!

-

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

+

Discover

+ {#if !loading && !deckEmpty} +

{totalRemaining} books left

+ {/if}
-
- - My Library - - +
- {:else} - {@const book = currentBook!} - -
- - - {#if nextNextBook} -
- {#if nextNextBook.cover} - - {:else} -
- {/if} + {#if loading} + +
+
+
+
+
+
- {/if} +
- - {#if nextBook} -
- {#if nextBook.cover} - - {:else} -
- {/if} + {:else if deckEmpty} + +
+
📚
+
+

All caught up!

+

+ 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} +

- {/if} +
+ + My Library + + +
+
- - {#if currentBook} - -
- - {#if book.cover} - {book.title} - {:else} -
- - - + {:else} + {@const book = currentBook!} + + +
+ + + {#if nextNextBook} +
+ {#if nextNextBook.cover} + + {:else} +
+ {/if}
{/if} - -
-
-

{book.title}

- {#if book.author} -

{book.author}

- {/if} -
- {#each parseBookGenres(book.genres).slice(0, 2) as genre} - {genre} - {/each} - {#if book.status} - {book.status} - {/if} - {#if book.total_chapters} - {book.total_chapters} ch. + + {#if nextBook} +
+ {#if nextBook.cover} + + {:else} +
{/if}
-
+ {/if} - + +
- LIKE -
+ {#if book.cover} + {book.title} + {:else} +
+ + + +
+ {/if} - -
- SKIP -
+ +
+
+

{book.title}

+ {#if book.author}

{book.author}

{/if} +
+ {#each parseBookGenres(book.genres).slice(0, 2) as genre} + {genre} + {/each} + {#if book.status} + {book.status} + {/if} + {#if book.total_chapters} + {book.total_chapters} ch. + {/if} +
+
- -
- READ NOW -
- - -
- NOPE + +
+ LIKE +
+
+ SKIP +
+
+ READ NOW +
- {/if} -
- -
- - + +
+ + + +
- - + + + {/if} +
+ + + - - -
- {/if}