Compare commits

...

7 Commits

Author SHA1 Message Date
Admin
809dc8d898 fix: make asynq consumer actually claim and heartbeat translation tasks
All checks were successful
Release / Check ui (push) Successful in 27s
Release / Test backend (push) Successful in 43s
Release / Docker / caddy (push) Successful in 1m3s
Release / Docker / ui (push) Successful in 1m58s
Release / Docker / runner (push) Successful in 3m23s
Release / Docker / backend (push) Successful in 4m26s
Release / Gitea Release (push) Successful in 13s
ClaimNextTranslationTask and HeartbeatTask were no-ops in the asynq
Consumer, so translation tasks created in PocketBase were never picked
up by the runner. Translation tasks live in PocketBase (not Redis),
so they must be claimed/heartbeated via the underlying pb consumer.
ReapStaleTasks is also delegated so stale translation tasks get reset.

Also removes the LibreTranslate healthcheck from homelab/runner
docker-compose.yml and relaxes depends_on to service_started — the
healthcheck was blocking runner startup until models loaded (~2 min)
and the models are already pre-downloaded in the volume.
2026-04-02 21:16:48 +05:00
Admin
e9c3426fbe feat: scroll active chapter into view when chapter drawer opens
All checks were successful
Release / Check ui (push) Successful in 40s
Release / Test backend (push) Successful in 43s
Release / Docker / caddy (push) Successful in 51s
Release / Docker / ui (push) Successful in 2m30s
Release / Docker / runner (push) Successful in 3m26s
Release / Docker / backend (push) Successful in 4m3s
Release / Gitea Release (push) Successful in 12s
When the mini-player chapter drawer is opened, the current chapter is
now immediately scrolled into the center of the list instead of always
starting from the top. Uses a Svelte action (setIfActive) to track the
active chapter element and a $effect to call scrollIntoView on open.
2026-04-02 20:44:12 +05:00
Admin
8e611840d1 fix: add 30s timeout to PB HTTP client; halve heartbeat tick interval
All checks were successful
Release / Test backend (push) Successful in 32s
Release / Docker / caddy (push) Successful in 42s
Release / Check ui (push) Successful in 44s
Release / Docker / ui (push) Successful in 2m32s
Release / Docker / backend (push) Successful in 2m49s
Release / Docker / runner (push) Successful in 3m26s
Release / Gitea Release (push) Successful in 15s
- storage/pocketbase.go: replace http.DefaultClient (no timeout) with a
  dedicated pbHTTPClient{Timeout: 30s} so a slow/hung PocketBase cannot
  stall the backend or runner indefinitely
- runner/asynq_runner.go: heartbeat ticker was firing at StaleTaskThreshold
  (2 min) == the Docker healthcheck deadline, so a single missed tick would
  mark the container unhealthy; halved to StaleTaskThreshold/2 (1 min)
2026-04-02 18:49:04 +05:00
Admin
b9383570e3 ci: fix duplicate runs — ignore tag pushes and remove pull_request trigger
All checks were successful
Release / Test backend (push) Successful in 24s
Release / Docker / caddy (push) Successful in 50s
Release / Check ui (push) Successful in 57s
Release / Docker / backend (push) Successful in 2m15s
Release / Docker / runner (push) Successful in 2m47s
Release / Docker / ui (push) Successful in 2m42s
Release / Gitea Release (push) Successful in 13s
2026-04-02 18:00:48 +05:00
Admin
eac9358c6f fix(discover): guard active card with {#if currentBook} to fix TS errors; use $state for cardEl bind
All checks were successful
CI / Backend (pull_request) Successful in 45s
CI / UI (pull_request) Successful in 26s
Release / Test backend (push) Successful in 23s
CI / UI (push) Successful in 39s
CI / Backend (push) Successful in 42s
Release / Check ui (push) Successful in 28s
Release / Docker / caddy (push) Successful in 54s
Release / Docker / runner (push) Successful in 2m12s
Release / Docker / ui (push) Successful in 1m58s
Release / Docker / backend (push) Successful in 3m16s
Release / Gitea Release (push) Successful in 13s
2026-04-02 17:48:25 +05:00
Admin
9cb11bc5e4 chore(pb): add discovery_votes collection to pb-init script
Some checks failed
CI / UI (pull_request) Failing after 30s
CI / Backend (pull_request) Successful in 34s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:38:15 +05:00
Admin
7196f8e930 feat(discover): Tinder-style book discovery + fix duplicate books
Some checks failed
CI / UI (push) Failing after 22s
CI / Backend (push) Successful in 53s
Release / Test backend (push) Successful in 24s
Release / Check ui (push) Failing after 24s
Release / Docker / ui (push) Has been skipped
CI / Backend (pull_request) Successful in 25s
CI / UI (pull_request) Failing after 22s
Release / Docker / caddy (push) Successful in 56s
Release / Docker / backend (push) Successful in 1m38s
Release / Docker / runner (push) Successful in 3m14s
Release / Gitea Release (push) Has been skipped
- New /discover page with swipe UI: left=skip, right=like, up=read now, down=nope
- Onboarding modal to collect genre/status preferences (persisted in localStorage)
- 3-card stack with pointer-event drag, CSS fly-out animation, 5 action buttons
- Tap card for preview modal; empty state with deck reset
- Like/read-now auto-saves book to user library
- POST /api/discover/vote + DELETE for deck reset
- Discovery vote persistence via PocketBase discovery_votes collection
- Fix duplicate books: dedup by slug in getBooksBySlugs
- Fix WriteMetadata TOCTOU race: conflict-retry on concurrent insert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:33:27 +05:00
12 changed files with 871 additions and 41 deletions

View File

@@ -2,11 +2,8 @@ name: CI
on:
push:
paths:
- "backend/**"
- "ui/**"
- ".gitea/workflows/ci.yaml"
pull_request:
tags-ignore:
- "v*"
paths:
- "backend/**"
- "ui/**"

View File

@@ -10,14 +10,13 @@ import (
// Consumer wraps the PocketBase-backed Consumer for result write-back only.
//
// When using Asynq, the runner no longer polls for work — Asynq delivers
// tasks via the ServeMux handlers. The only Consumer operations the handlers
// need are:
// - FinishAudioTask / FinishScrapeTask — write result back to PocketBase
// - FailTask — mark PocketBase record as failed
// When using Asynq, the runner no longer polls for scrape/audio work — Asynq
// delivers those tasks via the ServeMux handlers. However translation tasks
// live in PocketBase (not Redis), so ClaimNextTranslationTask and HeartbeatTask
// still delegate to the underlying PocketBase consumer.
//
// ClaimNextAudioTask, ClaimNextScrapeTask, HeartbeatTask, and ReapStaleTasks
// are all no-ops here because Asynq owns those responsibilities.
// ClaimNextAudioTask, ClaimNextScrapeTask are no-ops here because Asynq owns
// those responsibilities.
type Consumer struct {
pb taskqueue.Consumer // underlying PocketBase consumer (for write-back)
}
@@ -55,10 +54,18 @@ func (c *Consumer) ClaimNextAudioTask(_ context.Context, _ string) (domain.Audio
return domain.AudioTask{}, false, nil
}
func (c *Consumer) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
return domain.TranslationTask{}, false, nil
// ClaimNextTranslationTask delegates to PocketBase because translation tasks
// are stored in PocketBase (not Redis/Asynq) and must still be polled directly.
func (c *Consumer) ClaimNextTranslationTask(ctx context.Context, workerID string) (domain.TranslationTask, bool, error) {
return c.pb.ClaimNextTranslationTask(ctx, workerID)
}
func (c *Consumer) HeartbeatTask(_ context.Context, _ string) error { return nil }
func (c *Consumer) HeartbeatTask(ctx context.Context, id string) error {
return c.pb.HeartbeatTask(ctx, id)
}
func (c *Consumer) ReapStaleTasks(_ context.Context, _ time.Duration) (int, error) { return 0, nil }
// ReapStaleTasks delegates to PocketBase so stale translation tasks are reset
// to pending and can be reclaimed.
func (c *Consumer) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (int, error) {
return c.pb.ReapStaleTasks(ctx, staleAfter)
}

View File

@@ -78,7 +78,7 @@ func (r *Runner) runAsynq(ctx context.Context) error {
// Write /tmp/runner.alive every 30s so Docker healthcheck passes in asynq mode.
// This mirrors the heartbeat file behavior from the poll() loop.
go func() {
heartbeatTick := time.NewTicker(r.cfg.StaleTaskThreshold)
heartbeatTick := time.NewTicker(r.cfg.StaleTaskThreshold / 2)
defer heartbeatTick.Stop()
for {
select {

View File

@@ -26,6 +26,11 @@ import (
// ErrNotFound is returned by single-record lookups when no record exists.
var ErrNotFound = errors.New("storage: record not found")
// pbHTTPClient is a shared HTTP client with a 30 s timeout so that a slow or
// hung PocketBase never stalls the backend/runner process indefinitely.
// http.DefaultClient has no timeout and must not be used for PocketBase calls.
var pbHTTPClient = &http.Client{Timeout: 30 * time.Second}
// pbClient is the internal PocketBase REST admin client.
type pbClient struct {
baseURL string
@@ -66,7 +71,7 @@ func (c *pbClient) authToken(ctx context.Context) (string, error) {
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
resp, err := pbHTTPClient.Do(req)
if err != nil {
return "", fmt.Errorf("pb auth: %w", err)
}
@@ -104,7 +109,7 @@ func (c *pbClient) do(ctx context.Context, method, path string, body io.Reader)
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
resp, err := pbHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("pb: %s %s: %w", method, path, err)
}

View File

@@ -74,12 +74,24 @@ func (s *Store) WriteMetadata(ctx context.Context, meta domain.BookMeta) error {
"rating": meta.Rating,
}
// Upsert via filter: if exists PATCH, otherwise POST.
// Use a conflict-retry pattern to handle concurrent scrapes racing to insert
// the same slug: if POST fails (or another concurrent writer beat us to it),
// re-fetch and PATCH instead.
existing, err := s.getBookBySlug(ctx, meta.Slug)
if err != nil && err != ErrNotFound {
return fmt.Errorf("WriteMetadata: %w", err)
}
if err == ErrNotFound {
return s.pb.post(ctx, "/api/collections/books/records", payload, nil)
postErr := s.pb.post(ctx, "/api/collections/books/records", payload, nil)
if postErr == nil {
return nil
}
// POST failed — a concurrent writer may have inserted the same slug.
// Re-fetch and fall through to PATCH.
existing, err = s.getBookBySlug(ctx, meta.Slug)
if err != nil {
return postErr // original POST error is more informative
}
}
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/books/records/%s", existing.ID), payload)
}

View File

@@ -28,20 +28,13 @@ services:
volumes:
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
- libretranslate_db:/app/db
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request,sys; urllib.request.urlopen('http://localhost:5000/languages'); sys.exit(0)"]
interval: 30s
timeout: 10s
retries: 5
start_period: 120s
runner:
image: kalekber/libnovel-runner:latest
restart: unless-stopped
stop_grace_period: 135s
depends_on:
libretranslate:
condition: service_healthy
- libretranslate
environment:
# ── PocketBase ──────────────────────────────────────────────────────────
POCKETBASE_URL: "https://pb.libnovel.cc"

View File

@@ -259,6 +259,14 @@ create "translation_jobs" '{
{"name":"heartbeat_at", "type":"date"}
]}'
create "discovery_votes" '{
"name":"discovery_votes","type":"base","fields":[
{"name":"session_id","type":"text","required":true},
{"name":"user_id", "type":"text"},
{"name":"slug", "type":"text","required":true},
{"name":"action", "type":"text","required":true}
]}'
# ── 5. Field migrations (idempotent — adds fields missing from older installs) ─
add_field "scraping_tasks" "heartbeat_at" "date"
add_field "audio_jobs" "heartbeat_at" "date"

View File

@@ -258,8 +258,26 @@ export async function getBooksBySlugs(slugs: Iterable<string>): Promise<Book[]>
// Build filter: slug='a' || slug='b' || ...
const filter = slugArr.map((s) => `slug='${s.replace(/'/g, "\\'")}'`).join(' || ');
const books = await listAll<Book>('books', filter, '+title');
log.debug('pocketbase', 'getBooksBySlugs', { requested: slugArr.length, found: books.length });
return books;
// Deduplicate by slug — PocketBase may have multiple records for the same
// slug if the scraper ran concurrently or the upsert raced. First record wins.
const seen = new Set<string>();
const deduped = books.filter((b) => {
if (seen.has(b.slug)) return false;
seen.add(b.slug);
return true;
});
if (deduped.length !== books.length) {
log.warn('pocketbase', 'getBooksBySlugs: duplicate slugs in DB', {
requested: slugArr.length,
raw: books.length,
deduped: deduped.length
});
} else {
log.debug('pocketbase', 'getBooksBySlugs', { requested: slugArr.length, found: books.length });
}
return deduped;
}
/** Invalidate the books cache (call after a book is created/updated/deleted). */
@@ -1644,3 +1662,110 @@ export async function getSubscriptionFeed(
feed.sort((a, b) => b.updated.localeCompare(a.updated));
return feed.slice(0, limit).map(({ book, readerUsername }) => ({ book, readerUsername }));
}
// ─── Discovery ────────────────────────────────────────────────────────────────
// NOTE: Requires a `discovery_votes` collection in PocketBase with fields:
// - session_id (text, required)
// - user_id (text, optional)
// - slug (text, required)
// - action (text, required) — one of: like | skip | nope | read_now
export interface DiscoveryVote {
id?: string;
session_id: string;
user_id?: string;
slug: string;
action: 'like' | 'skip' | 'nope' | 'read_now';
}
export interface DiscoveryPrefs {
genres: string[];
status: 'either' | 'ongoing' | 'completed';
}
function parseGenresLocal(genres: string[] | string): string[] {
if (Array.isArray(genres)) return genres;
if (!genres) return [];
try { return JSON.parse(genres) as string[]; } catch { return []; }
}
function discoveryFilter(sessionId: string, userId?: string): string {
if (userId) return `user_id="${userId}"`;
return `session_id="${sessionId}"`;
}
export async function getVotedSlugs(sessionId: string, userId?: string): Promise<Set<string>> {
const rows = await listAll<DiscoveryVote>(
'discovery_votes',
discoveryFilter(sessionId, userId)
).catch(() => [] as DiscoveryVote[]);
return new Set(rows.map((r) => r.slug));
}
export async function upsertDiscoveryVote(
sessionId: string,
slug: string,
action: DiscoveryVote['action'],
userId?: string
): Promise<void> {
const filter = userId
? `user_id="${userId}"&&slug="${slug}"`
: `session_id="${sessionId}"&&slug="${slug}"`;
const existing = await listOne<DiscoveryVote & { id: string }>('discovery_votes', filter);
const payload: Partial<DiscoveryVote> = { session_id: sessionId, slug, action };
if (userId) payload.user_id = userId;
if (existing) {
const res = await pbPatch(`/api/collections/discovery_votes/records/${existing.id}`, payload);
if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote PATCH failed', { slug, status: res.status });
} else {
const res = await pbPost('/api/collections/discovery_votes/records', payload);
if (!res.ok) log.warn('pocketbase', 'upsertDiscoveryVote POST failed', { slug, status: res.status });
}
}
export async function clearDiscoveryVotes(sessionId: string, userId?: string): Promise<void> {
const filter = discoveryFilter(sessionId, userId);
const rows = await listAll<DiscoveryVote & { id: string }>('discovery_votes', filter).catch(() => []);
await Promise.all(
rows.map((r) =>
pbDelete(`/api/collections/discovery_votes/records/${r.id}`).catch(() => {})
)
);
}
export async function getBooksForDiscovery(
sessionId: string,
userId?: string,
prefs?: DiscoveryPrefs
): Promise<Book[]> {
const [allBooks, votedSlugs, savedSlugs] = await Promise.all([
listBooks(),
getVotedSlugs(sessionId, userId),
getSavedSlugs(sessionId, userId)
]);
let candidates = allBooks.filter((b) => !votedSlugs.has(b.slug) && !savedSlugs.has(b.slug));
if (prefs?.genres?.length) {
const preferred = new Set(prefs.genres.map((g) => g.toLowerCase()));
const genreFiltered = candidates.filter((b) => {
const genres = parseGenresLocal(b.genres);
return genres.some((g) => preferred.has(g.toLowerCase()));
});
if (genreFiltered.length >= 5) candidates = genreFiltered;
}
if (prefs?.status && prefs.status !== 'either') {
const sf = candidates.filter((b) => b.status?.toLowerCase().includes(prefs.status));
if (sf.length >= 3) candidates = sf;
}
// Fisher-Yates shuffle
for (let i = candidates.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
}
return candidates.slice(0, 50);
}

View File

@@ -33,6 +33,21 @@
// Chapter list drawer state for the mini-player
let chapterDrawerOpen = $state(false);
let activeChapterEl = $state<HTMLElement | null>(null);
function setIfActive(node: HTMLElement, isActive: boolean) {
if (isActive) activeChapterEl = node;
return {
update(nowActive: boolean) { if (nowActive) activeChapterEl = node; },
destroy() { if (activeChapterEl === node) activeChapterEl = null; }
};
}
$effect(() => {
if (chapterDrawerOpen && activeChapterEl) {
activeChapterEl.scrollIntoView({ block: 'center' });
}
});
// The single <audio> element that persists across navigations.
// AudioPlayer components in chapter pages control it via audioStore.
@@ -302,21 +317,18 @@
>
{m.nav_library()}
</a>
<a
href="/discover"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/discover') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Discover
</a>
<a
href="/catalogue"
class="hidden sm:block text-sm transition-colors {page.url.pathname.startsWith('/catalogue') ? 'text-(--color-text) font-medium' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
{m.nav_catalogue()}
</a>
<a
href="https://feedback.libnovel.cc"
target="_blank"
rel="noopener noreferrer"
class="hidden sm:block text-sm transition-colors text-(--color-muted) hover:text-(--color-text)"
>
{m.nav_feedback()}
</a>
<div class="ml-auto flex items-center gap-2">
<!-- Theme dropdown (desktop) -->
<div class="hidden sm:block relative">
@@ -417,6 +429,18 @@
{m.nav_admin_panel()}
</a>
{/if}
<a
href="https://feedback.libnovel.cc"
target="_blank"
rel="noopener noreferrer"
onclick={() => { userMenuOpen = false; }}
class="flex items-center justify-between gap-2 px-3 py-2 text-sm text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
>
{m.nav_feedback()}
<svg class="w-3 h-3 shrink-0 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<div class="my-1 border-t border-(--color-border)/60"></div>
<form method="POST" action="/logout">
<button type="submit" class="w-full text-left px-3 py-2 text-sm text-(--color-danger) hover:bg-(--color-surface-3) transition-colors">
@@ -478,6 +502,13 @@
>
{m.nav_library()}
</a>
<a
href="/discover"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors {page.url.pathname.startsWith('/discover') ? 'bg-(--color-surface-2) text-(--color-text)' : 'text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)'}"
>
Discover
</a>
<a
href="/catalogue"
onclick={() => (menuOpen = false)}
@@ -490,9 +521,12 @@
target="_blank"
rel="noopener noreferrer"
onclick={() => (menuOpen = false)}
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text)"
class="px-3 py-2.5 rounded-lg text-sm font-medium transition-colors text-(--color-muted) hover:bg-(--color-surface-2) hover:text-(--color-text) flex items-center justify-between"
>
{m.nav_feedback()}
{m.nav_feedback()}
<svg class="w-3.5 h-3.5 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
<a
href="/profile"
@@ -665,6 +699,7 @@
</div>
{#each audioStore.chapters as ch (ch.number)}
<a
use:setIfActive={ch.number === audioStore.chapter}
href="/books/{audioStore.slug}/chapters/{ch.number}"
onclick={() => (chapterDrawerOpen = false)}
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter

View File

@@ -0,0 +1,32 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { upsertDiscoveryVote, clearDiscoveryVotes, saveBook } from '$lib/server/pocketbase';
const VALID_ACTIONS = ['like', 'skip', 'nope', 'read_now'] as const;
type Action = (typeof VALID_ACTIONS)[number];
export const POST: RequestHandler = async ({ request, locals }) => {
const body = await request.json().catch(() => null);
if (!body || typeof body.slug !== 'string' || !VALID_ACTIONS.includes(body.action)) {
error(400, 'Expected { slug, action }');
}
const action = body.action as Action;
try {
await upsertDiscoveryVote(locals.sessionId, body.slug, action, locals.user?.id);
if (action === 'like' || action === 'read_now') {
await saveBook(locals.sessionId, body.slug, locals.user?.id);
}
} catch (e) {
error(500, 'Failed to record vote');
}
return json({ ok: true });
};
export const DELETE: RequestHandler = async ({ locals }) => {
try {
await clearDiscoveryVotes(locals.sessionId, locals.user?.id);
} catch {
error(500, 'Failed to clear votes');
}
return json({ ok: true });
};

View File

@@ -0,0 +1,14 @@
import type { PageServerLoad } from './$types';
import { getBooksForDiscovery } from '$lib/server/pocketbase';
import type { DiscoveryPrefs } from '$lib/server/pocketbase';
export const load: PageServerLoad = async ({ locals, url }) => {
let prefs: DiscoveryPrefs | undefined;
const prefsParam = url.searchParams.get('prefs');
if (prefsParam) {
try { prefs = JSON.parse(prefsParam) as DiscoveryPrefs; } catch { /* ignore */ }
}
const books = await getBooksForDiscovery(locals.sessionId, locals.user?.id, prefs).catch(() => []);
return { books };
};

View File

@@ -0,0 +1,602 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import type { PageData } from './$types';
import type { Book } from '$lib/server/pocketbase';
let { data }: { data: PageData } = $props();
// ── Preferences (localStorage) ──────────────────────────────────────────────
interface Prefs {
genres: string[];
status: 'either' | 'ongoing' | 'completed';
onboarded: boolean;
}
const PREFS_KEY = 'discover_prefs_v1';
const GENRES = [
'Martial Arts', 'Xianxia', 'Xuanhuan', 'Wuxia', 'Cultivation',
'Romance', 'Action', 'Adventure', 'Fantasy', 'System',
'Harem', 'Historical', 'Comedy', 'Drama', 'Mystery',
'Sci-Fi', 'Horror', 'Slice of Life'
];
function loadPrefs(): Prefs {
if (!browser) return { genres: [], status: 'either', onboarded: false };
try {
const raw = localStorage.getItem(PREFS_KEY);
if (raw) return JSON.parse(raw) as Prefs;
} catch { /* ignore */ }
return { genres: [], status: 'either', onboarded: false };
}
function savePrefs(p: Prefs) {
if (!browser) return;
localStorage.setItem(PREFS_KEY, JSON.stringify(p));
}
let prefs = $state<Prefs>(loadPrefs());
let showOnboarding = $state(!prefs.onboarded);
// Onboarding temp state
let tempGenres = $state<string[]>([...prefs.genres]);
let tempStatus = $state<Prefs['status']>(prefs.status);
function finishOnboarding(skip = false) {
if (!skip) {
prefs = { genres: tempGenres, status: tempStatus, onboarded: true };
} else {
prefs = { ...prefs, onboarded: true };
}
savePrefs(prefs);
showOnboarding = false;
}
// ── Book deck (client-side filtered) ───────────────────────────────────────
function parseBookGenres(genres: string[] | string): string[] {
if (Array.isArray(genres)) return genres;
if (!genres) return [];
try { return JSON.parse(genres) as string[]; } catch { return []; }
}
let deck = $derived.by(() => {
let books = data.books as Book[];
if (prefs.onboarded && prefs.genres.length > 0) {
const preferred = new Set(prefs.genres.map((g) => g.toLowerCase()));
const filtered = books.filter((b) => {
const g = parseBookGenres(b.genres);
return g.some((genre) => preferred.has(genre.toLowerCase()));
});
if (filtered.length >= 5) books = filtered;
}
if (prefs.onboarded && prefs.status !== 'either') {
const sf = books.filter((b) => b.status?.toLowerCase().includes(prefs.status));
if (sf.length >= 3) books = sf;
}
return books;
});
// ── Card state ──────────────────────────────────────────────────────────────
let idx = $state(0);
let isDragging = $state(false);
let animating = $state(false);
let offsetX = $state(0);
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 startX = 0, startY = 0, hasMoved = false;
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 totalRemaining = $derived(Math.max(0, deck.length - idx));
// Which direction/indicator to show
let indicator = $derived.by((): 'like' | 'skip' | 'read_now' | 'nope' | null => {
if (!isDragging) return null;
const ax = Math.abs(offsetX), ay = Math.abs(offsetY);
const threshold = 20;
if (ax > ay) {
if (offsetX > threshold) return 'like';
if (offsetX < -threshold) return 'skip';
} else {
if (offsetY < -threshold) return 'read_now';
if (offsetY > threshold) return 'nope';
}
return null;
});
const indicatorOpacity = $derived(
isDragging
? Math.min(1, Math.max(Math.abs(offsetX), Math.abs(offsetY)) / 60)
: 0
);
const rotation = $derived(isDragging ? Math.max(-18, Math.min(18, offsetX / 12)) : 0);
let cardEl = $state<HTMLDivElement | null>(null);
function onPointerDown(e: PointerEvent) {
if (animating || !currentBook) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
startX = e.clientX;
startY = e.clientY;
hasMoved = false;
isDragging = true;
transitioning = false;
offsetX = 0;
offsetY = 0;
}
function onPointerMove(e: PointerEvent) {
if (!isDragging) return;
offsetX = e.clientX - startX;
offsetY = e.clientY - startY;
if (Math.abs(offsetX) > 5 || Math.abs(offsetY) > 5) hasMoved = true;
}
async function onPointerUp(e: PointerEvent) {
if (!isDragging) return;
isDragging = false;
const THRESHOLD_X = 90;
const THRESHOLD_Y = 70;
const ax = Math.abs(offsetX), ay = Math.abs(offsetY);
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 (!hasMoved) {
// Tap without drag → preview
showPreview = true;
offsetX = 0; offsetY = 0;
} else {
// Snap back
transitioning = true;
offsetX = 0; offsetY = 0;
await delay(320);
transitioning = false;
}
}
function delay(ms: number) { return new Promise<void>((r) => setTimeout(r, ms)); }
type VoteAction = 'like' | 'skip' | 'nope' | '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) {
if (animating || !currentBook) return;
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 })
});
// Fly out
transitioning = true;
const target = flyTargets[action];
offsetX = target.x;
offsetY = target.y;
await delay(360);
// Advance
voted = { slug: book.slug, action };
idx++;
transitioning = false;
offsetX = 0;
offsetY = 0;
animating = false;
showPreview = false;
if (action === 'read_now') {
goto(`/books/${book.slug}`);
}
}
async function resetDeck() {
await fetch('/api/discover/vote', { method: 'DELETE' });
idx = 0;
// Reload page to get fresh server data
window.location.reload();
}
</script>
<!-- ── Onboarding modal ───────────────────────────────────────────────────────── -->
{#if showOnboarding}
<div class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div class="w-full max-w-md bg-(--color-surface-2) rounded-2xl border border-(--color-border) shadow-2xl overflow-hidden">
<div class="p-6">
<div class="mb-5">
<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">
{#each GENRES as genre}
<button
type="button"
onclick={() => {
if (tempGenres.includes(genre)) {
tempGenres = tempGenres.filter((g) => g !== genre);
} else {
tempGenres = [...tempGenres, genre];
}
}}
class="px-3 py-1.5 rounded-full text-sm font-medium border transition-all
{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>
{/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">
{#each (['either', 'ongoing', 'completed'] as const) as s}
<button
type="button"
onclick={() => (tempStatus = s)}
class="flex-1 py-2 rounded-xl text-sm font-medium border transition-all
{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>
{/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"
>
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"
>
Start Discovering
</button>
</div>
</div>
</div>
</div>
{/if}
<!-- ── Preview modal ───────────────────────────────────────────────────────────── -->
{#if showPreview && currentBook}
<div
class="fixed inset-0 z-40 flex items-end sm:items-center justify-center p-4"
onclick={() => (showPreview = false)}
>
<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"
onclick={(e) => e.stopPropagation()}
>
<!-- Cover strip -->
<div class="relative h-40 overflow-hidden">
{#if currentBook.cover}
<img src={currentBook.cover} alt={currentBook.title} class="w-full h-full object-cover object-top" />
<div class="absolute inset-0 bg-gradient-to-b from-transparent to-(--color-surface-2)"></div>
{:else}
<div class="w-full h-full bg-(--color-surface-3) flex items-center justify-center">
<svg class="w-12 h-12 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
{/if}
</div>
<div class="p-5">
<h3 class="font-bold text-(--color-text) text-lg leading-snug mb-1">{currentBook.title}</h3>
{#if currentBook.author}
<p class="text-sm text-(--color-muted) mb-3">{currentBook.author}</p>
{/if}
{#if currentBook.summary}
<p class="text-sm text-(--color-muted) leading-relaxed line-clamp-5 mb-4">{currentBook.summary}</p>
{/if}
<div class="flex flex-wrap gap-2 mb-5">
{#each parseBookGenres(currentBook.genres).slice(0, 4) as genre}
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
{/each}
{#if currentBook.status}
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-text)">{currentBook.status}</span>
{/if}
{#if currentBook.total_chapters}
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-muted)">{currentBook.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>
</div>
</div>
</div>
</div>
{/if}
<!-- ── Main layout ────────────────────────────────────────────────────────────── -->
<div class="min-h-screen bg-(--color-surface) flex flex-col items-center px-4 pt-8 pb-6 select-none">
<!-- Header -->
<div class="w-full max-w-sm flex items-center justify-between mb-6">
<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>
<button
type="button"
onclick={() => { showOnboarding = true; tempGenres = [...prefs.genres]; tempStatus = prefs.status; }}
title="Preferences"
class="w-8 h-8 flex items-center justify-center rounded-lg text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) 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="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>
{#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>
<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>
{:else}
<!-- Card stack -->
<div class="w-full max-w-sm relative" style="aspect-ratio: 3/4.2;">
<!-- 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}
<!-- 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>
{/if}
<!-- Active card -->
{#if currentBook}
<div
bind:this={cardEl}
class="absolute inset-0 rounded-2xl overflow-hidden shadow-2xl cursor-grab active:cursor-grabbing z-10"
style="
transform: translateX({offsetX}px) translateY({offsetY}px) rotate({rotation}deg);
transition: {(transitioning && !isDragging) ? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)' : 'none'};
touch-action: none;
"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<!-- Cover image -->
{#if currentBook.cover}
<img src={currentBook.cover} alt={currentBook.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}
<!-- Bottom gradient + info -->
<div class="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 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-xl leading-snug line-clamp-2 mb-1">{currentBook.title}</h2>
{#if currentBook.author}
<p class="text-white/70 text-sm mb-2">{currentBook.author}</p>
{/if}
<div class="flex flex-wrap gap-1.5 items-center">
{#each parseBookGenres(currentBook.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 currentBook.status}
<span class="text-xs bg-white/10 text-white/60 px-2 py-0.5 rounded-full">{currentBook.status}</span>
{/if}
{#if currentBook.total_chapters}
<span class="text-xs text-white/50 ml-auto">{currentBook.total_chapters} ch.</span>
{/if}
</div>
</div>
<!-- LIKE indicator (right swipe) -->
<div
class="absolute top-8 right-6 px-3 py-1.5 rounded-lg border-2 border-green-400 rotate-[-15deg] pointer-events-none"
style="opacity: {indicator === 'like' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
>
<span class="text-green-400 font-black text-lg tracking-widest">LIKE</span>
</div>
<!-- SKIP indicator (left swipe) -->
<div
class="absolute top-8 left-6 px-3 py-1.5 rounded-lg border-2 border-red-400 rotate-[15deg] pointer-events-none"
style="opacity: {indicator === 'skip' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
>
<span class="text-red-400 font-black text-lg tracking-widest">SKIP</span>
</div>
<!-- READ NOW indicator (swipe up) -->
<div
class="absolute top-8 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-lg border-2 border-blue-400 pointer-events-none"
style="opacity: {indicator === 'read_now' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
>
<span class="text-blue-400 font-black text-lg tracking-widest">READ NOW</span>
</div>
<!-- NOPE indicator (swipe down) -->
<div
class="absolute bottom-28 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-lg border-2 border-(--color-muted) pointer-events-none"
style="opacity: {indicator === 'nope' ? indicatorOpacity : 0}; transition: opacity 0.1s;"
>
<span class="text-(--color-muted) font-black text-lg tracking-widest">NOPE</span>
</div>
</div>
{/if}
</div>
<!-- Action buttons -->
<div class="w-full max-w-sm flex items-center justify-center gap-4 mt-6">
<!-- Skip (left) -->
<button
type="button"
onclick={() => doAction('skip')}
disabled={animating}
title="Skip"
class="w-14 h-14 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-red-400 hover:bg-red-400/10 hover:border-red-400/40 hover:scale-105 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>
</button>
<!-- Read Now (up) -->
<button
type="button"
onclick={() => doAction('read_now')}
disabled={animating}
title="Read Now"
class="w-12 h-12 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-blue-400 hover:bg-blue-400/10 hover:border-blue-400/40 hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
<!-- Preview (center) -->
<button
type="button"
onclick={() => (showPreview = true)}
disabled={animating}
title="Details"
class="w-10 h-10 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) hover:scale-105 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" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
<!-- Like (right) -->
<button
type="button"
onclick={() => doAction('like')}
disabled={animating}
title="Add to Library"
class="w-14 h-14 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-(--color-success) hover:bg-green-400/10 hover:border-green-400/40 hover:scale-105 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>
</button>
<!-- Nope (down) -->
<button
type="button"
onclick={() => doAction('nope')}
disabled={animating}
title="Never show again"
class="w-12 h-12 rounded-full bg-(--color-surface-2) border border-(--color-border) flex items-center justify-center text-(--color-muted) hover:text-(--color-muted)/60 hover:bg-(--color-surface-3) hover:scale-105 active:scale-95 transition-all disabled:opacity-40"
>
<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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
</svg>
</button>
</div>
<!-- Swipe hint (shown briefly) -->
<p class="mt-4 text-xs text-(--color-muted)/50 text-center">
Swipe or tap buttons · Tap card for details
</p>
{/if}
</div>