Compare commits

...

20 Commits

Author SHA1 Message Date
Admin
24d73cb730 fix: add device_fingerprint to PB schema + fix homelab Redis routing
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 43s
Release / Docker / caddy (push) Successful in 54s
Release / Docker / runner (push) Successful in 2m33s
Release / Docker / backend (push) Successful in 3m0s
Release / Docker / ui (push) Successful in 1m56s
Release / Gitea Release (push) Successful in 20s
OAuth login was silently failing: upsertUserSession() queried the
device_fingerprint column which didn't exist in the user_sessions
collection, PocketBase returned 400, the fallback authSessionId was
never written to the DB, and isSessionRevoked() immediately revoked
the cookie on first load after the OAuth redirect.

- scripts/pb-init-v3.sh: add device_fingerprint text field to the
  user_sessions create block (new installs) and add an idempotent
  add_field migration line (existing installs)

Audio jobs were stuck pending because the homelab runner was
connecting to its own local Redis instead of the prod VPS Redis.

- homelab/docker-compose.yml: change hardcoded REDIS_ADDR=redis:6379
  to ${REDIS_ADDR} so Doppler injects rediss://redis.libnovel.cc:6380
  (the Caddy TLS proxy that bridges the homelab runner to prod Redis)
2026-04-03 20:37:10 +05:00
Admin
19aeb90403 perf: cache home stats + ratings, fix discover card pop animation
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 48s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / runner (push) Successful in 2m48s
Release / Docker / backend (push) Successful in 2m52s
Release / Docker / ui (push) Successful in 2m4s
Release / Gitea Release (push) Successful in 21s
Cache home stats (10 min) and recently added books (5 min) to avoid
hitting PocketBase on every homepage load. Cache all ratings for
discovery ranking (5 min) with invalidation on setBookRating.
invalidateBooksCache now clears all related keys atomically.

Fix discover card pop-to-full-size bug: new card now transitions from
scale(0.95) to scale(1.0) matching its back-card position, instead of
snapping to full size instantly after each swipe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 20:15:58 +05:00
Admin
06d4a7bfd4 feat: profile stats, discover history, end-of-chapter sleep, rating-ranked deck
All checks were successful
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 52s
Release / Docker / backend (push) Successful in 2m32s
Release / Docker / ui (push) Successful in 2m15s
Release / Docker / runner (push) Successful in 2m50s
Release / Gitea Release (push) Successful in 22s
**Profile stats tab**
- New Stats tab on /profile page (Profile / Stats switcher)
- Reading overview: chapters read, completed, reading, plan-to-read counts
- Activity cards: day streak + avg rating given
- Favourite genres (top 3 by frequency across library/progress)
- getUserStats() in pocketbase.ts — computes streak, shelf counts, genre freq

**Discover history tab**
- New History tab on /discover with full voted-book list
- Per-entry: cover thumbnail, title link, author, action label (Liked/Skipped/etc.)
- Undo button: optimistic update + DELETE /api/discover/vote?slug=...
- Clear all history button; tab shows vote count badge
- getVotedBooks(), undoDiscoveryVote() in pocketbase.ts

**Rating-ranked discovery deck**
- getBooksForDiscovery now sorts by community avg rating before returning
- Tier-based shuffle: books within the same ±0.5 star bucket are still randomised
- Higher-rated books surface earlier without making the deck fully deterministic

**End-of-chapter sleep timer**
- New cycle option: Off → End of Chapter → 15m → 30m → 45m → 60m → Off
- sleepAfterChapter flag in AudioStore; layout handles it in onended (skips auto-next)
- Button shows "End Ch." label when active in this mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 07:26:54 +05:00
Admin
73a92ccf8f fix: deduplicate sessions with device fingerprint upsert
All checks were successful
Release / Test backend (push) Successful in 29s
Release / Check ui (push) Successful in 41s
Release / Docker / caddy (push) Successful in 50s
Release / Docker / ui (push) Successful in 2m8s
Release / Docker / backend (push) Successful in 3m17s
Release / Docker / runner (push) Successful in 3m13s
Release / Gitea Release (push) Successful in 12s
OAuth callbacks were creating a new session record on every login from
the same device because user-agent/IP were hardcoded as empty strings,
producing a pile-up of 6+ identical 'Unknown browser' sessions.

- Add upsertUserSession(): looks up existing session by user_id +
  device_fingerprint (SHA-256 of ua::ip, first 16 hex chars); reuses
  and touches it (returning the same authSessionId) if found, creates
  a new record otherwise
- Add device_fingerprint field to UserSession interface
- Fix OAuth callback to read real user-agent/IP from request headers
  (they are available in RequestHandler via request.headers)
- Switch both OAuth and password login to upsertUserSession so the
  returned authSessionId is used for the auth token
- Extend pruneStaleUserSessions to also cap sessions at 10 per user
- Keep createUserSession as a deprecated shim for gradual migration
2026-04-02 22:22:17 +05:00
Admin
08361172c6 feat: ratings, shelves, sleep timer, EPUB export + fix TS errors
All checks were successful
Release / Docker / caddy (push) Successful in 38s
Release / Check ui (push) Successful in 39s
Release / Test backend (push) Successful in 57s
Release / Docker / ui (push) Successful in 1m49s
Release / Docker / runner (push) Successful in 3m12s
Release / Docker / backend (push) Successful in 3m46s
Release / Gitea Release (push) Successful in 13s
**Ratings (1–5 stars)**
- New `book_ratings` PB collection (session_id, user_id, slug, rating)
- `getBookRating`, `getBookAvgRating`, `setBookRating` in pocketbase.ts
- GET/POST /api/ratings/[slug] API route
- StarRating.svelte component with hover, animated stars, avg display
- Star rating shown on book detail page (desktop + mobile)

**Plan-to-Read shelf**
- `shelf` field added to `user_library` (reading/plan_to_read/completed/dropped)
- `updateBookShelf`, `getShelfMap` in pocketbase.ts
- PATCH /api/library/[slug] for shelf updates
- Shelf selector dropdown on book detail page (only when saved)
- Shelf tabs on library page to filter by category

**Sleep timer**
- `sleepUntil` state added to AudioStore
- Layout handles timer lifecycle (survives chapter navigation)
- Cycles Off → 15m → 30m → 45m → 60m → Off
- Shows live countdown in AudioPlayer when active

**EPUB export**
- Go backend: GET /api/export/{slug}?from=N&to=N
- Generates valid EPUB2 zip (mimetype uncompressed, OPF, NCX, XHTML chapters)
- Markdown → HTML via goldmark
- SvelteKit proxy at /api/export/[slug]
- Download button on book detail page (only when in library)

**Fix TS errors**
- discover/+page.svelte: currentBook possibly undefined (use {@const book})
- cardEl now $state for reactive binding

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 21:50:04 +05:00
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
Admin
a771405db8 feat(audio): WAV streaming, bulk audio generation admin endpoints, cancel/resume
Some checks failed
CI / Backend (push) Successful in 48s
CI / UI (push) Successful in 28s
Release / Check ui (push) Successful in 39s
Release / Test backend (push) Successful in 49s
Release / Docker / caddy (push) Successful in 59s
CI / Backend (pull_request) Successful in 41s
CI / UI (pull_request) Successful in 46s
Release / Docker / ui (push) Successful in 1m31s
Release / Docker / backend (push) Successful in 3m27s
Release / Docker / runner (push) Successful in 3m47s
Release / Gitea Release (push) Failing after 32s
- Add StreamAudioWAV() to pocket-tts and Kokoro clients; pocket-tts streams
  raw WAV directly (no ffmpeg), Kokoro requests response_format:wav with stream:true
- GET /api/audio-stream supports ?format=wav for lower-latency first-byte delivery;
  WAV cached separately in MinIO as {slug}/{n}/{voice}.wav
- Add GET /api/admin/audio/jobs with optional ?slug filter
- Add POST /api/admin/audio/bulk {slug, voice, from, to, skip_existing, force}
  where skip_existing=true (default) resumes interrupted bulk jobs
- Add POST /api/admin/audio/cancel-bulk {slug} to cancel all pending/running tasks
- Add CancelAudioTasksBySlug to taskqueue.Producer + asynqqueue implementation
- Add AudioObjectKeyExt to bookstore.AudioStore for format-aware MinIO keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 16:19:14 +05:00
Admin
1e9a96aa0f fix(payments): fix TypeScript cast errors in polar webhook handler
Some checks failed
CI / UI (push) Successful in 25s
Release / Test backend (push) Successful in 22s
CI / Backend (push) Successful in 1m59s
Release / Check ui (push) Successful in 27s
Release / Docker / caddy (push) Successful in 44s
CI / UI (pull_request) Successful in 27s
CI / Backend (pull_request) Failing after 1m26s
Release / Docker / runner (push) Successful in 1m40s
Release / Docker / backend (push) Successful in 2m34s
Release / Docker / ui (push) Successful in 3m21s
Release / Gitea Release (push) Successful in 13s
Cast through unknown to satisfy TS strict overlap check for
PolarSubscription and PolarOrder types from Record<string, unknown>.
2026-03-31 23:40:11 +05:00
Admin
23ae1ed500 feat(payments): lock checkout email via Polar server-side checkout sessions
Some checks failed
CI / UI (pull_request) Failing after 23s
CI / Backend (push) Successful in 27s
CI / Backend (pull_request) Successful in 53s
CI / UI (push) Failing after 25s
Release / Test backend (push) Successful in 28s
Release / Check ui (push) Failing after 30s
Release / Docker / ui (push) Has been skipped
Release / Docker / caddy (push) Successful in 42s
Release / Docker / backend (push) Successful in 1m50s
Release / Docker / runner (push) Successful in 3m47s
Release / Gitea Release (push) Has been skipped
Replace static Polar checkout links with a server-side POST /api/checkout
route that creates a checkout session with customer_external_id = user ID
and customer_email locked (not editable). Adds loading/error states and
a post-checkout success banner on the profile page.
2026-03-31 23:36:53 +05:00
Admin
e7cb460f9b fix(payments): point manage subscription to org customer portal
Some checks failed
CI / Backend (pull_request) Successful in 32s
CI / UI (pull_request) Failing after 28s
CI / Backend (push) Successful in 26s
CI / UI (push) Failing after 25s
Release / Check ui (push) Failing after 16s
Release / Docker / ui (push) Has been skipped
Release / Test backend (push) Successful in 47s
Release / Docker / caddy (push) Successful in 50s
Release / Docker / runner (push) Successful in 2m0s
Release / Docker / backend (push) Successful in 2m59s
Release / Gitea Release (push) Has been skipped
2026-03-31 23:26:57 +05:00
Admin
392248e8a6 fix(payments): update Polar checkout links to use checkout link IDs
Some checks failed
CI / Backend (pull_request) Successful in 25s
CI / UI (pull_request) Failing after 16s
CI / UI (push) Failing after 17s
CI / Backend (push) Successful in 41s
Release / Test backend (push) Successful in 40s
Release / Docker / caddy (push) Successful in 54s
Release / Docker / backend (push) Successful in 1m55s
Release / Docker / runner (push) Successful in 2m52s
Release / Docker / ui (push) Has been cancelled
Release / Gitea Release (push) Has been cancelled
Release / Check ui (push) Has been cancelled
2026-03-31 23:25:26 +05:00
Admin
68ea2d2808 feat(payments): fix Polar webhook + pre-fill checkout email
Some checks failed
CI / Backend (pull_request) Successful in 26s
CI / UI (pull_request) Failing after 24s
CI / Backend (push) Successful in 26s
Release / Check ui (push) Failing after 16s
Release / Docker / ui (push) Has been skipped
CI / UI (push) Failing after 31s
Release / Test backend (push) Successful in 41s
Release / Docker / caddy (push) Successful in 33s
Release / Docker / runner (push) Successful in 2m58s
Release / Docker / backend (push) Successful in 3m43s
Release / Gitea Release (push) Has been skipped
- Fix customer email path: was data.customer_email, is actually
  data.customer.email per Polar v1 API schema
- Add resolveUser() helper: tries polar_customer_id → email → external_id
- Add subscription.active and subscription.canceled event handling
- Handle order.created for fast-path pro upgrade on purchase
- Profile page: fetch user email + polarCustomerId from PocketBase
- Profile page: pre-fill ?customer_email= on checkout links
- Profile page: link to polar.sh/purchases for existing customers
2026-03-31 23:11:34 +05:00
Admin
7b1df9b592 fix(infra): fix libretranslate healthcheck; fix scrollbar-none css
All checks were successful
CI / Backend (push) Successful in 25s
Release / Test backend (push) Successful in 24s
CI / UI (push) Successful in 51s
Release / Check ui (push) Successful in 29s
CI / UI (pull_request) Successful in 27s
CI / Backend (pull_request) Successful in 44s
Release / Docker / caddy (push) Successful in 54s
Release / Docker / ui (push) Successful in 2m4s
Release / Docker / runner (push) Successful in 2m56s
Release / Docker / backend (push) Successful in 3m23s
Release / Gitea Release (push) Successful in 12s
2026-03-31 22:36:19 +05:00
Admin
f4089fe111 fix(admin): add layout guard and redirect /admin to /admin/scrape
All checks were successful
CI / Backend (push) Successful in 45s
CI / UI (push) Successful in 56s
Release / Check ui (push) Successful in 26s
Release / Test backend (push) Successful in 39s
CI / Backend (pull_request) Successful in 24s
Release / Docker / caddy (push) Successful in 48s
CI / UI (pull_request) Successful in 40s
Release / Docker / runner (push) Successful in 2m52s
Release / Docker / backend (push) Successful in 3m27s
Release / Docker / ui (push) Successful in 3m38s
Release / Gitea Release (push) Successful in 14s
- Add +layout.server.ts to enforce admin role check at layout level,
  preventing 404 on /admin and protecting all sub-routes centrally
- Add +page.server.ts to redirect /admin → /admin/scrape (was 404)
2026-03-31 22:33:39 +05:00
47 changed files with 2878 additions and 179 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

@@ -200,6 +200,10 @@ func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadClos
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}
func (n *noopKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
return nil, nil
}

View File

@@ -227,6 +227,10 @@ func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadClos
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}
func (n *noopKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}
func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
return nil, nil
}

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

@@ -93,6 +93,12 @@ func (p *Producer) CancelTask(ctx context.Context, id string) error {
return p.pb.CancelTask(ctx, id)
}
// CancelAudioTasksBySlug delegates to PocketBase to cancel all pending/running
// audio tasks for slug.
func (p *Producer) CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error) {
return p.pb.CancelAudioTasksBySlug(ctx, slug)
}
// enqueue serialises payload and dispatches it to Asynq.
func (p *Producer) enqueue(_ context.Context, taskType string, payload any) error {
b, err := json.Marshal(payload)

View File

@@ -0,0 +1,143 @@
package backend
import (
"archive/zip"
"bytes"
"fmt"
"strings"
)
type epubChapter struct {
Number int
Title string
HTML string
}
func generateEPUB(slug, title, author string, chapters []epubChapter) ([]byte, error) {
var buf bytes.Buffer
w := zip.NewWriter(&buf)
// 1. mimetype — MUST be first, MUST be uncompressed (Store method)
mw, err := w.CreateHeader(&zip.FileHeader{
Name: "mimetype",
Method: zip.Store,
})
if err != nil {
return nil, err
}
mw.Write([]byte("application/epub+zip"))
// 2. META-INF/container.xml
addFile(w, "META-INF/container.xml", containerXML())
// 3. OEBPS/style.css
addFile(w, "OEBPS/style.css", epubCSS())
// 4. OEBPS/content.opf
addFile(w, "OEBPS/content.opf", contentOPF(slug, title, author, chapters))
// 5. OEBPS/toc.ncx
addFile(w, "OEBPS/toc.ncx", tocNCX(slug, title, chapters))
// 6. Chapter files
for _, ch := range chapters {
name := fmt.Sprintf("OEBPS/chapter-%04d.xhtml", ch.Number)
addFile(w, name, chapterXHTML(ch))
}
w.Close()
return buf.Bytes(), nil
}
func addFile(w *zip.Writer, name, content string) {
f, _ := w.Create(name)
f.Write([]byte(content))
}
func containerXML() string {
return `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`
}
func contentOPF(slug, title, author string, chapters []epubChapter) string {
var items, spine strings.Builder
for _, ch := range chapters {
id := fmt.Sprintf("ch%04d", ch.Number)
href := fmt.Sprintf("chapter-%04d.xhtml", ch.Number)
items.WriteString(fmt.Sprintf(` <item id="%s" href="%s" media-type="application/xhtml+xml"/>`+"\n", id, href))
spine.WriteString(fmt.Sprintf(` <itemref idref="%s"/>`+"\n", id))
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="uid" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>%s</dc:title>
<dc:creator>%s</dc:creator>
<dc:identifier id="uid">%s</dc:identifier>
<dc:language>en</dc:language>
</metadata>
<manifest>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
<item id="css" href="style.css" media-type="text/css"/>
%s </manifest>
<spine toc="ncx">
%s </spine>
</package>`, escapeXML(title), escapeXML(author), slug, items.String(), spine.String())
}
func tocNCX(slug, title string, chapters []epubChapter) string {
var points strings.Builder
for i, ch := range chapters {
chTitle := ch.Title
if chTitle == "" {
chTitle = fmt.Sprintf("Chapter %d", ch.Number)
}
points.WriteString(fmt.Sprintf(` <navPoint id="np%d" playOrder="%d">
<navLabel><text>%s</text></navLabel>
<content src="chapter-%04d.xhtml"/>
</navPoint>`+"\n", i+1, i+1, escapeXML(chTitle), ch.Number))
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN" "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head><meta name="dtb:uid" content="%s"/></head>
<docTitle><text>%s</text></docTitle>
<navMap>
%s </navMap>
</ncx>`, slug, escapeXML(title), points.String())
}
func chapterXHTML(ch epubChapter) string {
title := ch.Title
if title == "" {
title = fmt.Sprintf("Chapter %d", ch.Number)
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>%s</title><link rel="stylesheet" href="style.css"/></head>
<body>
<h1 class="chapter-title">%s</h1>
%s
</body>
</html>`, escapeXML(title), escapeXML(title), ch.HTML)
}
func epubCSS() string {
return `body { font-family: Georgia, serif; font-size: 1em; line-height: 1.6; margin: 1em 2em; }
h1.chapter-title { font-size: 1.4em; margin-bottom: 1em; }
p { margin: 0 0 0.8em 0; text-indent: 1.5em; }
p:first-of-type { text-indent: 0; }
`
}
func escapeXML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}

View File

@@ -708,12 +708,17 @@ func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
// Fast path: if audio already exists in MinIO, redirects to the presigned URL
// (same as handleAudioProxy) — the client plays from storage immediately.
//
// Slow path (first request): streams MP3 audio directly to the client while
// simultaneously uploading it to MinIO. After the stream completes, any
// pending audio_jobs task for this key is marked done. Subsequent requests hit
// the fast path and skip TTS generation entirely.
// Slow path (first request): streams audio directly to the client while
// simultaneously uploading it to MinIO. After the stream completes, subsequent
// requests hit the fast path and skip TTS generation entirely.
//
// Query params: voice (optional, defaults to DefaultVoice)
// Query params:
//
// voice (optional, defaults to DefaultVoice)
// format (optional, "mp3" or "wav"; defaults to "mp3")
//
// Using format=wav skips the ffmpeg transcode for pocket-tts voices, delivering
// raw WAV frames to the client with lower latency at the cost of larger files.
func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
@@ -727,7 +732,17 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
voice = s.cfg.DefaultVoice
}
audioKey := s.deps.AudioStore.AudioObjectKey(slug, n, voice)
format := r.URL.Query().Get("format")
if format != "wav" {
format = "mp3"
}
contentType := "audio/mpeg"
if format == "wav" {
contentType = "audio/wav"
}
audioKey := s.deps.AudioStore.AudioObjectKeyExt(slug, n, voice, format)
// ── Fast path: already in MinIO ───────────────────────────────────────────
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
@@ -756,23 +771,39 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
return
}
// Open the TTS stream.
// Open the TTS stream (WAV or MP3 depending on format param).
var audioStream io.ReadCloser
if pockettts.IsPocketTTSVoice(voice) {
if s.deps.PocketTTS == nil {
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
return
if format == "wav" {
if pockettts.IsPocketTTSVoice(voice) {
if s.deps.PocketTTS == nil {
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
return
}
audioStream, err = s.deps.PocketTTS.StreamAudioWAV(r.Context(), text, voice)
} else {
if s.deps.Kokoro == nil {
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
return
}
audioStream, err = s.deps.Kokoro.StreamAudioWAV(r.Context(), text, voice)
}
audioStream, err = s.deps.PocketTTS.StreamAudioMP3(r.Context(), text, voice)
} else {
if s.deps.Kokoro == nil {
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
return
if pockettts.IsPocketTTSVoice(voice) {
if s.deps.PocketTTS == nil {
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
return
}
audioStream, err = s.deps.PocketTTS.StreamAudioMP3(r.Context(), text, voice)
} else {
if s.deps.Kokoro == nil {
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
return
}
audioStream, err = s.deps.Kokoro.StreamAudioMP3(r.Context(), text, voice)
}
audioStream, err = s.deps.Kokoro.StreamAudioMP3(r.Context(), text, voice)
}
if err != nil {
s.deps.Log.Error("handleAudioStream: TTS stream failed", "slug", slug, "n", n, "voice", voice, "err", err)
s.deps.Log.Error("handleAudioStream: TTS stream failed", "slug", slug, "n", n, "voice", voice, "format", format, "err", err)
jsonError(w, http.StatusInternalServerError, "tts stream failed")
return
}
@@ -787,11 +818,11 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
go func() {
uploadDone <- s.deps.AudioStore.PutAudioStream(
context.Background(), // use background — request ctx may cancel after client disconnects
audioKey, pr, -1, "audio/mpeg",
audioKey, pr, -1, contentType,
)
}()
w.Header().Set("Content-Type", "audio/mpeg")
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Accel-Buffering", "no") // disable nginx/caddy buffering
w.WriteHeader(http.StatusOK)
@@ -1081,6 +1112,166 @@ func (s *Server) handleAdminTranslationBulk(w http.ResponseWriter, r *http.Reque
})
}
// ── Admin Audio ────────────────────────────────────────────────────────────────
// handleAdminAudioJobs handles GET /api/admin/audio/jobs.
// Returns all audio jobs, optionally filtered by slug (?slug=...).
// Sorted by started descending.
func (s *Server) handleAdminAudioJobs(w http.ResponseWriter, r *http.Request) {
tasks, err := s.deps.TaskReader.ListAudioTasks(r.Context())
if err != nil {
s.deps.Log.Error("handleAdminAudioJobs: ListAudioTasks failed", "err", err)
jsonError(w, http.StatusInternalServerError, "failed to list audio jobs")
return
}
// Optional slug filter.
slugFilter := r.URL.Query().Get("slug")
type jobRow struct {
ID string `json:"id"`
CacheKey string `json:"cache_key"`
Slug string `json:"slug"`
Chapter int `json:"chapter"`
Voice string `json:"voice"`
Status string `json:"status"`
WorkerID string `json:"worker_id"`
ErrorMessage string `json:"error_message"`
Started string `json:"started"`
Finished string `json:"finished"`
}
rows := make([]jobRow, 0, len(tasks))
for _, t := range tasks {
if slugFilter != "" && t.Slug != slugFilter {
continue
}
rows = append(rows, jobRow{
ID: t.ID,
CacheKey: t.CacheKey,
Slug: t.Slug,
Chapter: t.Chapter,
Voice: t.Voice,
Status: string(t.Status),
WorkerID: t.WorkerID,
ErrorMessage: t.ErrorMessage,
Started: t.Started.Format(time.RFC3339),
Finished: t.Finished.Format(time.RFC3339),
})
}
writeJSON(w, 0, map[string]any{"jobs": rows, "total": len(rows)})
}
// handleAdminAudioBulk handles POST /api/admin/audio/bulk.
// Body: {"slug": "...", "voice": "af_bella", "from": 1, "to": 100, "skip_existing": true}
//
// Enqueues one audio task per chapter in [from, to].
// skip_existing (default true): skip chapters already cached in MinIO — use this
// to resume a previously interrupted bulk job.
// force: if true, enqueue even when a pending/running task already exists.
// Max 1000 chapters per request.
func (s *Server) handleAdminAudioBulk(w http.ResponseWriter, r *http.Request) {
var body struct {
Slug string `json:"slug"`
Voice string `json:"voice"`
From int `json:"from"`
To int `json:"to"`
SkipExisting *bool `json:"skip_existing"` // pointer so we can detect omission
Force bool `json:"force"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if body.Slug == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
if body.Voice == "" {
body.Voice = s.cfg.DefaultVoice
}
if body.From < 1 || body.To < body.From {
jsonError(w, http.StatusBadRequest, "from must be >= 1 and to must be >= from")
return
}
if body.To-body.From > 999 {
jsonError(w, http.StatusBadRequest, "range too large; max 1000 chapters per request")
return
}
// skip_existing defaults to true (resume-friendly).
skipExisting := true
if body.SkipExisting != nil {
skipExisting = *body.SkipExisting
}
var taskIDs []string
skipped := 0
for n := body.From; n <= body.To; n++ {
// Skip chapters already cached in MinIO.
if skipExisting {
audioKey := s.deps.AudioStore.AudioObjectKey(body.Slug, n, body.Voice)
if s.deps.AudioStore.AudioExists(r.Context(), audioKey) {
skipped++
continue
}
}
// Skip chapters with an active (pending/running) task unless force=true.
if !body.Force {
cacheKey := fmt.Sprintf("%s/%d/%s", body.Slug, n, body.Voice)
existing, found, _ := s.deps.TaskReader.GetAudioTask(r.Context(), cacheKey)
if found && (existing.Status == domain.TaskStatusPending || existing.Status == domain.TaskStatusRunning) {
skipped++
continue
}
}
id, err := s.deps.Producer.CreateAudioTask(r.Context(), body.Slug, n, body.Voice)
if err != nil {
s.deps.Log.Error("handleAdminAudioBulk: CreateAudioTask failed",
"slug", body.Slug, "chapter", n, "voice", body.Voice, "err", err)
jsonError(w, http.StatusInternalServerError,
fmt.Sprintf("failed to create task for chapter %d: %s", n, err))
return
}
taskIDs = append(taskIDs, id)
}
writeJSON(w, http.StatusAccepted, map[string]any{
"enqueued": len(taskIDs),
"skipped": skipped,
"task_ids": taskIDs,
})
}
// handleAdminAudioCancelBulk handles POST /api/admin/audio/cancel-bulk.
// Body: {"slug": "..."}
// Cancels all pending and running audio tasks for the given slug.
func (s *Server) handleAdminAudioCancelBulk(w http.ResponseWriter, r *http.Request) {
var body struct {
Slug string `json:"slug"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if body.Slug == "" {
jsonError(w, http.StatusBadRequest, "slug is required")
return
}
cancelled, err := s.deps.Producer.CancelAudioTasksBySlug(r.Context(), body.Slug)
if err != nil {
s.deps.Log.Error("handleAdminAudioCancelBulk: CancelAudioTasksBySlug failed",
"slug", body.Slug, "err", err)
jsonError(w, http.StatusInternalServerError, "failed to cancel tasks")
return
}
writeJSON(w, 0, map[string]any{"cancelled": cancelled})
}
// ── Voices ─────────────────────────────────────────────────────────────────────
// Returns {"voices": [...]} — merged list from Kokoro and pocket-tts.
func (s *Server) handleVoices(w http.ResponseWriter, r *http.Request) {
@@ -1538,6 +1729,109 @@ func stripMarkdown(src string) string {
return strings.TrimSpace(src)
}
// ── EPUB export ───────────────────────────────────────────────────────────────
// handleExportEPUB handles GET /api/export/{slug}.
// Generates and streams an EPUB file for the book identified by slug.
// Optional query params: from=N&to=N to limit the chapter range (default: all).
func (s *Server) handleExportEPUB(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if slug == "" {
jsonError(w, http.StatusBadRequest, "missing slug")
return
}
ctx := r.Context()
// Parse optional from/to range.
fromStr := r.URL.Query().Get("from")
toStr := r.URL.Query().Get("to")
fromN, toN := 0, 0
if fromStr != "" {
v, err := strconv.Atoi(fromStr)
if err != nil || v < 1 {
jsonError(w, http.StatusBadRequest, "invalid 'from' param")
return
}
fromN = v
}
if toStr != "" {
v, err := strconv.Atoi(toStr)
if err != nil || v < 1 {
jsonError(w, http.StatusBadRequest, "invalid 'to' param")
return
}
toN = v
}
// Fetch book metadata for title and author.
meta, inLib, err := s.deps.BookReader.ReadMetadata(ctx, slug)
if err != nil || !inLib {
s.deps.Log.Warn("handleExportEPUB: book not found", "slug", slug, "err", err)
jsonError(w, http.StatusNotFound, "book not found")
return
}
// List all chapters.
chapters, err := s.deps.BookReader.ListChapters(ctx, slug)
if err != nil {
s.deps.Log.Error("handleExportEPUB: ListChapters failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, "failed to list chapters")
return
}
// Filter chapters by from/to range.
var filtered []epubChapter
for _, ch := range chapters {
if fromN > 0 && ch.Number < fromN {
continue
}
if toN > 0 && ch.Number > toN {
continue
}
// Fetch markdown from MinIO.
mdText, readErr := s.deps.BookReader.ReadChapter(ctx, slug, ch.Number)
if readErr != nil {
s.deps.Log.Warn("handleExportEPUB: ReadChapter failed", "slug", slug, "n", ch.Number, "err", readErr)
// Skip chapters that cannot be fetched.
continue
}
// Convert markdown to HTML using goldmark.
md := goldmark.New()
var htmlBuf bytes.Buffer
if convErr := md.Convert([]byte(mdText), &htmlBuf); convErr != nil {
htmlBuf.Reset()
htmlBuf.WriteString("<p>" + mdText + "</p>")
}
filtered = append(filtered, epubChapter{
Number: ch.Number,
Title: ch.Title,
HTML: htmlBuf.String(),
})
}
if len(filtered) == 0 {
jsonError(w, http.StatusNotFound, "no chapters found in the requested range")
return
}
epubBytes, err := generateEPUB(slug, meta.Title, meta.Author, filtered)
if err != nil {
s.deps.Log.Error("handleExportEPUB: generateEPUB failed", "slug", slug, "err", err)
jsonError(w, http.StatusInternalServerError, "failed to generate EPUB")
return
}
w.Header().Set("Content-Type", "application/epub+zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.epub"`, slug))
w.Header().Set("Content-Length", strconv.Itoa(len(epubBytes)))
w.WriteHeader(http.StatusOK)
w.Write(epubBytes)
}
// ── Hardcoded Kokoro voice fallback ───────────────────────────────────────────
// kokoroVoiceIDs is the built-in fallback list of Kokoro voice IDs used when

View File

@@ -174,6 +174,11 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("GET /api/admin/translation/jobs", s.handleAdminTranslationJobs)
mux.HandleFunc("POST /api/admin/translation/bulk", s.handleAdminTranslationBulk)
// Admin audio endpoints
mux.HandleFunc("GET /api/admin/audio/jobs", s.handleAdminAudioJobs)
mux.HandleFunc("POST /api/admin/audio/bulk", s.handleAdminAudioBulk)
mux.HandleFunc("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk)
// Voices list
mux.HandleFunc("GET /api/voices", s.handleVoices)
@@ -185,6 +190,9 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
mux.HandleFunc("PUT /api/avatar-upload/{userId}", s.handleAvatarUpload)
// EPUB export
mux.HandleFunc("GET /api/export/{slug}", s.handleExportEPUB)
// Reading progress
mux.HandleFunc("GET /api/progress", s.handleGetProgress)
mux.HandleFunc("POST /api/progress/{slug}", s.handleSetProgress)

View File

@@ -80,9 +80,14 @@ type RankingStore interface {
// AudioStore covers audio object storage (runner writes; backend reads).
type AudioStore interface {
// AudioObjectKey returns the MinIO object key for a cached audio file.
// AudioObjectKey returns the MinIO object key for a cached MP3 audio file.
// Format: {slug}/{n}/{voice}.mp3
AudioObjectKey(slug string, n int, voice string) string
// AudioObjectKeyExt returns the MinIO object key for a cached audio file
// with a custom extension (e.g. "mp3" or "wav").
AudioObjectKeyExt(slug string, n int, voice, ext string) string
// AudioExists returns true when the audio object is present in MinIO.
AudioExists(ctx context.Context, key string) bool
@@ -91,7 +96,7 @@ type AudioStore interface {
// PutAudioStream uploads audio from r to MinIO under key.
// size must be the exact byte length of r, or -1 to use multipart upload.
// contentType should be "audio/mpeg".
// contentType should be "audio/mpeg" or "audio/wav".
PutAudioStream(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
}

View File

@@ -52,9 +52,10 @@ func (m *mockStore) RankingFreshEnough(_ context.Context, _ time.Duration) (bool
}
// AudioStore
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
func (m *mockStore) AudioObjectKey(_ string, _ int, _ string) string { return "" }
func (m *mockStore) AudioObjectKeyExt(_ string, _ int, _, _ string) string { return "" }
func (m *mockStore) AudioExists(_ context.Context, _ string) bool { return false }
func (m *mockStore) PutAudio(_ context.Context, _ string, _ []byte) error { return nil }
func (m *mockStore) PutAudioStream(_ context.Context, _ string, _ io.Reader, _ int64, _ string) error {
return nil
}

View File

@@ -27,6 +27,11 @@ type Client interface {
// waiting for the full output. The caller must always close the ReadCloser.
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
// StreamAudioWAV synthesises text and returns an io.ReadCloser that streams
// WAV-encoded audio incrementally using kokoro-fastapi's streaming mode with
// response_format:"wav". The caller must always close the ReadCloser.
StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error)
// ListVoices returns the available voice IDs. Falls back to an empty slice
// on error — callers should treat an empty list as "service unavailable".
ListVoices(ctx context.Context) ([]string, error)
@@ -167,6 +172,47 @@ func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io
return resp.Body, nil
}
// StreamAudioWAV calls POST /v1/audio/speech with stream:true and response_format:wav,
// returning an io.ReadCloser that delivers WAV bytes as kokoro generates them.
func (c *httpClient) StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error) {
if text == "" {
return nil, fmt.Errorf("kokoro: empty text")
}
if voice == "" {
voice = "af_bella"
}
reqBody, err := json.Marshal(map[string]any{
"model": "kokoro",
"input": text,
"voice": voice,
"response_format": "wav",
"speed": 1.0,
"stream": true,
})
if err != nil {
return nil, fmt.Errorf("kokoro: marshal wav stream request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/v1/audio/speech", bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("kokoro: build wav stream request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("kokoro: wav stream request: %w", err)
}
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("kokoro: wav stream returned %d", resp.StatusCode)
}
return resp.Body, nil
}
// ListVoices calls GET /v1/audio/voices and returns the list of voice IDs.
func (c *httpClient) ListVoices(ctx context.Context) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,

View File

@@ -59,6 +59,12 @@ type Client interface {
// The caller must always close the returned ReadCloser.
StreamAudioMP3(ctx context.Context, text, voice string) (io.ReadCloser, error)
// StreamAudioWAV synthesises text and returns an io.ReadCloser that streams
// raw WAV audio directly from pocket-tts without any transcoding.
// The stream begins with a WAV header followed by 16-bit PCM frames at 16 kHz.
// The caller must always close the returned ReadCloser.
StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error)
// ListVoices returns the available predefined voice names.
ListVoices(ctx context.Context) ([]string, error)
}
@@ -160,6 +166,25 @@ func (c *httpClient) StreamAudioMP3(ctx context.Context, text, voice string) (io
return pr, nil
}
// StreamAudioWAV posts to POST /tts and returns an io.ReadCloser that delivers
// raw WAV bytes directly from pocket-tts — no ffmpeg transcoding required.
// The first bytes will be a WAV header (RIFF/fmt chunk) followed by PCM frames.
// The caller must always close the returned ReadCloser.
func (c *httpClient) StreamAudioWAV(ctx context.Context, text, voice string) (io.ReadCloser, error) {
if text == "" {
return nil, fmt.Errorf("pockettts: empty text")
}
if voice == "" {
voice = "alba"
}
resp, err := c.postTTS(ctx, text, voice)
if err != nil {
return nil, err
}
return resp.Body, nil
}
// ListVoices returns the statically known predefined voice names.
// pocket-tts has no REST endpoint for listing voices.
func (c *httpClient) ListVoices(_ context.Context) ([]string, error) {

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

@@ -126,6 +126,9 @@ type stubAudioStore struct {
func (s *stubAudioStore) AudioObjectKey(slug string, n int, voice string) string {
return slug + "/" + string(rune('0'+n)) + "/" + voice + ".mp3"
}
func (s *stubAudioStore) AudioObjectKeyExt(slug string, n int, voice, ext string) string {
return slug + "/" + string(rune('0'+n)) + "/" + voice + "." + ext
}
func (s *stubAudioStore) AudioExists(_ context.Context, _ string) bool { return false }
func (s *stubAudioStore) PutAudio(_ context.Context, _ string, _ []byte) error {
s.putCalled.Add(1)
@@ -199,6 +202,14 @@ func (s *stubKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadClos
return io.NopCloser(bytes.NewReader(s.data)), nil
}
func (s *stubKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
s.called.Add(1)
if s.genErr != nil {
return nil, s.genErr
}
return io.NopCloser(bytes.NewReader(s.data)), nil
}
func (s *stubKokoro) ListVoices(_ context.Context) ([]string, error) {
return []string{"af_bella"}, nil
}

View File

@@ -109,10 +109,17 @@ func ChapterObjectKey(slug string, n int) string {
return fmt.Sprintf("%s/chapter-%06d.md", slug, n)
}
// AudioObjectKey returns the MinIO object key for a cached audio file.
// AudioObjectKeyExt returns the MinIO object key for a cached audio file
// with a custom extension (e.g. "mp3" or "wav").
// Format: {slug}/{n}/{voice}.{ext}
func AudioObjectKeyExt(slug string, n int, voice, ext string) string {
return fmt.Sprintf("%s/%d/%s.%s", slug, n, voice, ext)
}
// AudioObjectKey returns the MinIO object key for a cached MP3 audio file.
// Format: {slug}/{n}/{voice}.mp3
func AudioObjectKey(slug string, n int, voice string) string {
return fmt.Sprintf("%s/%d/%s.mp3", slug, n, voice)
return AudioObjectKeyExt(slug, n, voice, "mp3")
}
// AvatarObjectKey returns the MinIO object key for a user avatar image.

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)
}
@@ -376,6 +388,10 @@ func (s *Store) AudioObjectKey(slug string, n int, voice string) string {
return AudioObjectKey(slug, n, voice)
}
func (s *Store) AudioObjectKeyExt(slug string, n int, voice, ext string) string {
return AudioObjectKeyExt(slug, n, voice, ext)
}
func (s *Store) AudioExists(ctx context.Context, key string) bool {
return s.mc.objectExists(ctx, s.mc.bucketAudio, key)
}
@@ -574,6 +590,28 @@ func (s *Store) CancelTask(ctx context.Context, id string) error {
map[string]string{"status": string(domain.TaskStatusCancelled)})
}
func (s *Store) CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error) {
filter := fmt.Sprintf(`slug='%s'&&(status='pending'||status='running')`, slug)
items, err := s.pb.listAll(ctx, "audio_jobs", filter, "")
if err != nil {
return 0, fmt.Errorf("CancelAudioTasksBySlug list: %w", err)
}
cancelled := 0
for _, raw := range items {
var rec struct {
ID string `json:"id"`
}
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
if patchErr := s.pb.patch(ctx,
fmt.Sprintf("/api/collections/audio_jobs/records/%s", rec.ID),
map[string]string{"status": string(domain.TaskStatusCancelled)}); patchErr == nil {
cancelled++
}
}
}
return cancelled, nil
}
// ── taskqueue.Consumer ────────────────────────────────────────────────────────
func (s *Store) ClaimNextScrapeTask(ctx context.Context, workerID string) (domain.ScrapeTask, bool, error) {

View File

@@ -36,6 +36,10 @@ type Producer interface {
// CancelTask transitions a pending task to status=cancelled.
// Returns ErrNotFound if the task does not exist.
CancelTask(ctx context.Context, id string) error
// CancelAudioTasksBySlug cancels all pending or running audio tasks for slug.
// Returns the number of tasks cancelled.
CancelAudioTasksBySlug(ctx context.Context, slug string) (int, error)
}
// Consumer is the read/claim side of the task queue used by the runner.

View File

@@ -26,7 +26,8 @@ func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string
func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) {
return "translation-1", nil
}
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
func (s *stubStore) CancelAudioTasksBySlug(_ context.Context, _ string) (int, error) { return 0, nil }
func (s *stubStore) ClaimNextScrapeTask(_ context.Context, _ string) (domain.ScrapeTask, bool, error) {
return domain.ScrapeTask{ID: "task-1", Status: domain.TaskStatusRunning}, true, nil

View File

@@ -63,7 +63,7 @@ services:
LIBRETRANSLATE_API_KEY: "${LIBRETRANSLATE_API_KEY}"
# ── Asynq / Redis ─────────────────────────────────────────────────────
REDIS_ADDR: "redis:6379"
REDIS_ADDR: "${REDIS_ADDR}"
REDIS_PASSWORD: "${REDIS_PASSWORD}"
KOKORO_URL: "http://kokoro-fastapi:8880"

View File

@@ -28,20 +28,13 @@ services:
volumes:
- libretranslate_models:/home/libretranslate/.local/share/argos-translate
- libretranslate_db:/app/db
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:5000/languages || exit 1"]
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

@@ -190,14 +190,15 @@ create "app_users" '{
{"name":"oauth_id", "type":"text"}
]}'
create "user_sessions" '{
create "user_sessions" '{
"name":"user_sessions","type":"base","fields":[
{"name":"user_id", "type":"text","required":true},
{"name":"session_id","type":"text","required":true},
{"name":"user_agent","type":"text"},
{"name":"ip", "type":"text"},
{"name":"created_at","type":"text"},
{"name":"last_seen", "type":"text"}
{"name":"user_id", "type":"text","required":true},
{"name":"session_id", "type":"text","required":true},
{"name":"user_agent", "type":"text"},
{"name":"ip", "type":"text"},
{"name":"device_fingerprint", "type":"text"},
{"name":"created_at", "type":"text"},
{"name":"last_seen", "type":"text"}
]}'
create "user_library" '{
@@ -259,6 +260,22 @@ 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}
]}'
create "book_ratings" '{
"name":"book_ratings","type":"base","fields":[
{"name":"session_id","type":"text", "required":true},
{"name":"user_id", "type":"text"},
{"name":"slug", "type":"text", "required":true},
{"name":"rating", "type":"number", "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"
@@ -274,5 +291,7 @@ add_field "app_users" "oauth_provider" "text"
add_field "app_users" "oauth_id" "text"
add_field "app_users" "polar_customer_id" "text"
add_field "app_users" "polar_subscription_id" "text"
add_field "user_library" "shelf" "text"
add_field "user_sessions" "device_fingerprint" "text"
log "done"

View File

@@ -147,6 +147,15 @@ html {
margin: 2em 0;
}
/* ── Hide scrollbars (used on horizontal carousels) ────────────────── */
.scrollbar-none {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE / Edge legacy */
}
.scrollbar-none::-webkit-scrollbar {
display: none; /* Chrome / Safari / WebKit */
}
/* ── Navigation progress bar ───────────────────────────────────────── */
@keyframes progress-bar {
0% { width: 0%; opacity: 1; }

View File

@@ -75,6 +75,13 @@ class AudioStore {
*/
seekRequest = $state<number | null>(null);
// ── Sleep timer ──────────────────────────────────────────────────────────
/** Epoch ms when sleep timer should fire. 0 = off. */
sleepUntil = $state(0);
/** When true, pause after the current chapter ends instead of navigating. */
sleepAfterChapter = $state(false);
// ── Auto-next ────────────────────────────────────────────────────────────
/**
* When true, navigates to the next chapter when the current one ends

View File

@@ -681,6 +681,53 @@
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
// ── Sleep timer ────────────────────────────────────────────────────────────
const SLEEP_OPTIONS = [15, 30, 45, 60]; // minutes
let _tick = $state(0);
$effect(() => {
if (!audioStore.sleepUntil) return;
const id = setInterval(() => { _tick++; }, 1000);
return () => clearInterval(id);
});
let sleepRemainingSec = $derived.by(() => {
_tick; // subscribe to tick updates
if (!audioStore.sleepUntil) return 0;
return Math.max(0, Math.floor((audioStore.sleepUntil - Date.now()) / 1000));
});
function cycleSleepTimer() {
// Currently: no timer active at all
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = true;
return;
}
// Currently: end-of-chapter mode — move to 15m
if (audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = false;
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[0] * 60 * 1000;
return;
}
// Currently: timed mode — cycle to next or turn off
const remaining = audioStore.sleepUntil - Date.now();
const currentMin = Math.round(remaining / 60000);
const idx = SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
if (idx === -1 || idx === SLEEP_OPTIONS.length - 1) {
audioStore.sleepUntil = 0;
} else {
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[idx + 1] * 60 * 1000;
}
}
function formatSleepRemaining(secs: number): string {
if (secs <= 0) return '';
const m = Math.floor(secs / 60);
const s = secs % 60;
if (m > 0) return `${m}m`;
return `${s}s`;
}
</script>
<svelte:window onkeydown={handleKeyDown} />
@@ -887,6 +934,30 @@
{m.reader_auto_next()}
</Button>
{/if}
<!-- Sleep timer -->
<Button
variant="ghost"
size="sm"
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
onclick={cycleSleepTimer}
title={audioStore.sleepAfterChapter
? 'Stop after this chapter'
: audioStore.sleepUntil
? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining`
: 'Sleep timer off'}
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
{#if audioStore.sleepAfterChapter}
End Ch.
{:else if audioStore.sleepUntil}
{formatSleepRemaining(sleepRemainingSec)}
{:else}
Sleep
{/if}
</Button>
</div>
<!-- Next chapter pre-fetch status (only when auto-next is on) -->

View File

@@ -0,0 +1,57 @@
<script lang="ts">
interface Props {
rating: number; // current user rating 05 (0 = unrated)
avg?: number; // average rating
count?: number; // total ratings
readonly?: boolean; // display-only mode
size?: 'sm' | 'md';
onrate?: (r: number) => void;
}
let { rating = 0, avg = 0, count = 0, readonly = false, size = 'md', onrate }: Props = $props();
let hovered = $state(0);
const starSize = $derived(size === 'sm' ? 'w-3.5 h-3.5' : 'w-5 h-5');
const display = $derived(hovered || rating || 0);
</script>
<div class="flex items-center gap-1">
<div class="flex items-center gap-0.5">
{#each [1,2,3,4,5] as star}
<button
type="button"
disabled={readonly}
onmouseenter={() => { if (!readonly) hovered = star; }}
onmouseleave={() => { if (!readonly) hovered = 0; }}
onclick={() => { if (!readonly) onrate?.(star); }}
class="transition-transform {readonly ? 'cursor-default' : 'cursor-pointer hover:scale-110 active:scale-95'} disabled:pointer-events-none"
aria-label="Rate {star} star{star !== 1 ? 's' : ''}"
>
<svg
class="{starSize} transition-colors"
fill={star <= display ? 'currentColor' : 'none'}
stroke="currentColor"
stroke-width="1.5"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
/>
</svg>
</button>
{/each}
</div>
{#if avg && count}
<span class="text-xs text-(--color-muted) ml-1">{avg} ({count})</span>
{:else if avg}
<span class="text-xs text-(--color-muted) ml-1">{avg}</span>
{/if}
</div>
<style>
button[disabled] { pointer-events: none; }
svg { color: #f59e0b; }
</style>

View File

@@ -211,6 +211,20 @@ async function listOne<T>(collection: string, filter: string, sort = ''): Promis
const BOOKS_CACHE_KEY = 'books:all';
const BOOKS_CACHE_TTL = 5 * 60; // 5 minutes
const RATINGS_CACHE_KEY = 'book_ratings:all';
const RATINGS_CACHE_TTL = 5 * 60; // 5 minutes
const HOME_STATS_CACHE_KEY = 'home:stats';
const HOME_STATS_CACHE_TTL = 10 * 60; // 10 minutes — counts don't need to be exact
async function getAllRatings(): Promise<BookRating[]> {
const cached = await cache.get<BookRating[]>(RATINGS_CACHE_KEY);
if (cached) return cached;
const ratings = await listAll<BookRating>('book_ratings', '').catch(() => [] as BookRating[]);
await cache.set(RATINGS_CACHE_KEY, ratings, RATINGS_CACHE_TTL);
return ratings;
}
export async function listBooks(): Promise<Book[]> {
const cached = await cache.get<Book[]>(BOOKS_CACHE_KEY);
if (cached) {
@@ -258,13 +272,35 @@ 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). */
export async function invalidateBooksCache(): Promise<void> {
await cache.invalidate(BOOKS_CACHE_KEY);
await Promise.all([
cache.invalidate(BOOKS_CACHE_KEY),
cache.invalidate(HOME_STATS_CACHE_KEY),
cache.invalidatePattern('books:recent:*')
]);
}
export async function getBook(slug: string): Promise<Book | null> {
@@ -272,7 +308,12 @@ export async function getBook(slug: string): Promise<Book | null> {
}
export async function recentlyAddedBooks(limit = 6): Promise<Book[]> {
return listN<Book>('books', limit, '', '-meta_updated');
const key = `books:recent:${limit}`;
const cached = await cache.get<Book[]>(key);
if (cached) return cached;
const books = await listN<Book>('books', limit, '', '-meta_updated');
await cache.set(key, books, 5 * 60); // 5 minutes
return books;
}
export interface HomeStats {
@@ -281,11 +322,19 @@ export interface HomeStats {
}
export async function getHomeStats(): Promise<HomeStats> {
const cached = await cache.get<HomeStats>(HOME_STATS_CACHE_KEY);
if (cached) return cached;
const [totalBooks, totalChapters] = await Promise.all([
countCollection('books'),
countCollection('chapters_idx')
]);
return { totalBooks, totalChapters };
const stats = { totalBooks, totalChapters };
await cache.set(HOME_STATS_CACHE_KEY, stats, HOME_STATS_CACHE_TTL);
return stats;
}
export async function invalidateHomeStatsCache(): Promise<void> {
await cache.invalidate(HOME_STATS_CACHE_KEY);
}
// ─── Chapter index ────────────────────────────────────────────────────────────
@@ -524,7 +573,7 @@ export async function unsaveBook(
// ─── Users ────────────────────────────────────────────────────────────────────
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto';
import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex');
@@ -985,12 +1034,79 @@ export interface UserSession {
session_id: string; // the auth session ID embedded in the token
user_agent: string;
ip: string;
device_fingerprint: string;
created_at: string;
last_seen: string;
}
/**
* Create a new session record on login. Returns the record ID.
* Generate a short device fingerprint from user-agent + IP.
* SHA-256 of the concatenation, first 16 hex chars.
*/
function deviceFingerprint(userAgent: string, ip: string): string {
return createHash('sha256')
.update(`${userAgent}::${ip}`)
.digest('hex')
.slice(0, 16);
}
/**
* Upsert a session record on login.
* - If a session already exists for this user + device fingerprint, touch it and
* return the existing authSessionId (so the caller can reuse the same token).
* - Otherwise create a new record.
* Returns `{ authSessionId, recordId }`.
*/
export async function upsertUserSession(
userId: string,
authSessionId: string,
userAgent: string,
ip: string
): Promise<{ authSessionId: string; recordId: string }> {
const fp = deviceFingerprint(userAgent, ip);
// Look for an existing session from the same device
const existing = await listOne<UserSession>(
'user_sessions',
`user_id="${userId}" && device_fingerprint="${fp}"`
);
if (existing) {
// Touch last_seen and return the existing authSessionId
const token = await getToken();
await fetch(`${PB_URL}/api/collections/user_sessions/records/${existing.id}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ last_seen: new Date().toISOString() })
}).catch(() => {});
return { authSessionId: existing.session_id, recordId: existing.id };
}
// Create a new session record
const now = new Date().toISOString();
const res = await pbPost('/api/collections/user_sessions/records', {
user_id: userId,
session_id: authSessionId,
user_agent: userAgent,
ip,
device_fingerprint: fp,
created_at: now,
last_seen: now
});
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'upsertUserSession POST failed', { userId, status: res.status, body });
throw new Error(`Failed to create session: ${res.status}`);
}
const rec = (await res.json()) as { id: string };
// Best-effort: prune stale/excess sessions in the background
pruneStaleUserSessions(userId).catch(() => {});
return { authSessionId, recordId: rec.id };
}
/**
* @deprecated Use upsertUserSession instead.
* Kept temporarily so callers can be migrated incrementally.
*/
export async function createUserSession(
userId: string,
@@ -998,24 +1114,8 @@ export async function createUserSession(
userAgent: string,
ip: string
): Promise<string> {
const now = new Date().toISOString();
const res = await pbPost('/api/collections/user_sessions/records', {
user_id: userId,
session_id: authSessionId,
user_agent: userAgent,
ip,
created_at: now,
last_seen: now
});
if (!res.ok) {
const body = await res.text().catch(() => '');
log.error('pocketbase', 'createUserSession POST failed', { userId, status: res.status, body });
throw new Error(`Failed to create session: ${res.status}`);
}
const rec = (await res.json()) as { id: string };
// Best-effort: prune stale sessions in the background so the list doesn't grow forever
pruneStaleUserSessions(userId).catch(() => {});
return rec.id;
const { recordId } = await upsertUserSession(userId, authSessionId, userAgent, ip);
return recordId;
}
/**
@@ -1052,20 +1152,37 @@ export async function listUserSessions(userId: string): Promise<UserSession[]> {
}
/**
* Delete sessions for a user that haven't been seen in the last `days` days.
* Delete sessions for a user that haven't been seen in the last `days` days,
* and cap the total number of sessions at `maxSessions` (pruning oldest first).
* Called on login so the list self-cleans without a separate cron job.
*/
async function pruneStaleUserSessions(userId: string, days = 30): Promise<void> {
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
const stale = await listAll<UserSession>(
'user_sessions',
`user_id="${userId}" && last_seen<"${cutoff}"`
);
if (stale.length === 0) return;
async function pruneStaleUserSessions(
userId: string,
days = 30,
maxSessions = 10
): Promise<void> {
const token = await getToken();
const all = await listAll<UserSession>('user_sessions', `user_id="${userId}"`, '-last_seen');
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
const toDelete = new Set<string>();
// Mark stale sessions
for (const s of all) {
if (s.last_seen < cutoff) toDelete.add(s.id);
}
// Mark excess sessions beyond the cap (oldest first — list is sorted -last_seen)
const remaining = all.filter((s) => !toDelete.has(s.id));
if (remaining.length > maxSessions) {
remaining.slice(maxSessions).forEach((s) => toDelete.add(s.id));
}
if (toDelete.size === 0) return;
await Promise.all(
stale.map((s) =>
fetch(`${PB_URL}/api/collections/user_sessions/records/${s.id}`, {
[...toDelete].map((id) =>
fetch(`${PB_URL}/api/collections/user_sessions/records/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
}).catch(() => {})
@@ -1644,3 +1761,353 @@ 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(() => {})
)
);
}
// ─── Ratings ──────────────────────────────────────────────────────────────────
export interface BookRating {
session_id: string;
user_id?: string;
slug: string;
rating: number; // 15
}
export async function getBookRating(
sessionId: string,
slug: string,
userId?: string
): Promise<number> {
const filter = userId
? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
: `session_id="${sessionId}" && slug="${slug}"`;
const row = await listOne<BookRating>('book_ratings', filter).catch(() => null);
return row?.rating ?? 0;
}
export async function getBookAvgRating(
slug: string
): Promise<{ avg: number; count: number }> {
const rows = await listAll<BookRating>('book_ratings', `slug="${slug}"`).catch(() => []);
if (!rows.length) return { avg: 0, count: 0 };
const avg = rows.reduce((s, r) => s + r.rating, 0) / rows.length;
return { avg: Math.round(avg * 10) / 10, count: rows.length };
}
export async function setBookRating(
sessionId: string,
slug: string,
rating: number,
userId?: string
): Promise<void> {
const filter = userId
? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
: `session_id="${sessionId}" && slug="${slug}"`;
const existing = await listOne<BookRating & { id: string }>('book_ratings', filter).catch(() => null);
const payload: Partial<BookRating> = { session_id: sessionId, slug, rating };
if (userId) payload.user_id = userId;
if (existing) {
await pbPatch(`/api/collections/book_ratings/records/${existing.id}`, payload);
} else {
await pbPost('/api/collections/book_ratings/records', payload);
}
await cache.invalidate(RATINGS_CACHE_KEY);
}
// ─── Shelves ───────────────────────────────────────────────────────────────────
export type ShelfName = '' | 'plan_to_read' | 'completed' | 'dropped';
export async function updateBookShelf(
sessionId: string,
slug: string,
shelf: ShelfName,
userId?: string
): Promise<void> {
const filter = userId
? `(session_id="${sessionId}" || user_id="${userId}") && slug="${slug}"`
: `session_id="${sessionId}" && slug="${slug}"`;
const existing = await listOne<{ id: string }>('user_library', filter).catch(() => null);
if (!existing) {
// Save + set shelf in one shot
const payload: Record<string, unknown> = { session_id: sessionId, slug, shelf, saved_at: new Date().toISOString() };
if (userId) payload.user_id = userId;
await pbPost('/api/collections/user_library/records', payload);
} else {
await pbPatch(`/api/collections/user_library/records/${existing.id}`, { shelf });
}
}
export async function getShelfMap(
sessionId: string,
userId?: string
): Promise<Record<string, ShelfName>> {
const filter = userId
? `session_id="${sessionId}" || user_id="${userId}"`
: `session_id="${sessionId}"`;
const rows = await listAll<{ slug: string; shelf: string }>('user_library', filter).catch(() => []);
const map: Record<string, ShelfName> = {};
for (const r of rows) map[r.slug] = (r.shelf as ShelfName) || '';
return map;
}
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;
}
// Fetch avg ratings for candidates, weight top-rated books to surface earlier.
// Fetch in one shot for all candidate slugs. Low-rated / unrated books still
// appear — they're just pushed further back via a stable sort before shuffle.
const ratingRows = await getAllRatings();
const ratingMap = new Map<string, { sum: number; count: number }>();
for (const r of ratingRows) {
const cur = ratingMap.get(r.slug) ?? { sum: 0, count: 0 };
cur.sum += r.rating;
cur.count += 1;
ratingMap.set(r.slug, cur);
}
const avgRating = (slug: string) => {
const e = ratingMap.get(slug);
return e && e.count > 0 ? e.sum / e.count : 0;
};
// Sort by avg desc (unrated = 0, treated as unknown → middle of pack after rated)
// Then apply Fisher-Yates only within each rating tier so ordering feels natural.
candidates.sort((a, b) => avgRating(b.slug) - avgRating(a.slug));
// Shuffle within rating tiers (±0.5 star buckets) to avoid pure determinism
const tierOf = (slug: string) => Math.round(avgRating(slug) * 2); // 010
let start = 0;
while (start < candidates.length) {
let end = start + 1;
while (end < candidates.length && tierOf(candidates[end].slug) === tierOf(candidates[start].slug)) end++;
for (let i = end - 1; i > start; i--) {
const j = start + Math.floor(Math.random() * (i - start + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
}
start = end;
}
return candidates.slice(0, 50);
}
// ─── Discovery history ─────────────────────────────────────────────────────────
export interface VotedBook {
slug: string;
action: DiscoveryVote['action'];
votedAt: string;
book?: Book;
}
export async function getVotedBooks(
sessionId: string,
userId?: string
): Promise<VotedBook[]> {
const votes = await listAll<DiscoveryVote & { id: string; created: string }>(
'discovery_votes',
discoveryFilter(sessionId, userId),
'-created'
).catch(() => []);
if (!votes.length) return [];
const slugs = [...new Set(votes.map((v) => v.slug))];
const books = await getBooksBySlugs(new Set(slugs)).catch(() => [] as Book[]);
const bookMap = new Map(books.map((b) => [b.slug, b]));
return votes.map((v) => ({
slug: v.slug,
action: v.action,
votedAt: v.created,
book: bookMap.get(v.slug)
}));
}
export async function undoDiscoveryVote(
sessionId: string,
slug: string,
userId?: string
): Promise<void> {
const filter = `${discoveryFilter(sessionId, userId)}&&slug="${slug}"`;
const row = await listOne<{ id: string }>('discovery_votes', filter).catch(() => null);
if (row) {
await pbDelete(`/api/collections/discovery_votes/records/${row.id}`).catch(() => {});
}
}
// ─── User stats ────────────────────────────────────────────────────────────────
export interface UserStats {
totalChaptersRead: number;
booksReading: number;
booksCompleted: number;
booksPlanToRead: number;
booksDropped: number;
topGenres: string[]; // top 3 by frequency
avgRatingGiven: number; // 0 if no ratings
streak: number; // consecutive days with progress
}
export async function getUserStats(
sessionId: string,
userId?: string
): Promise<UserStats> {
const filter = userId ? `user_id="${userId}"` : `session_id="${sessionId}"`;
const [progressRows, libraryRows, ratingRows, allBooks] = await Promise.all([
listAll<Progress & { updated: string }>('progress', filter, '-updated').catch(() => []),
listAll<{ slug: string; shelf: string }>('user_library', filter).catch(() => []),
listAll<BookRating>('book_ratings', filter).catch(() => []),
listBooks().catch(() => [] as Book[])
]);
// shelf counts
const shelfCounts = { reading: 0, completed: 0, plan_to_read: 0, dropped: 0 };
for (const r of libraryRows) {
const s = r.shelf || 'reading';
if (s in shelfCounts) shelfCounts[s as keyof typeof shelfCounts]++;
}
// top genres from books in progress/library
const libSlugs = new Set(libraryRows.map((r) => r.slug));
const progSlugs = new Set(progressRows.map((r) => r.slug));
const allSlugs = new Set([...libSlugs, ...progSlugs]);
const bookMap = new Map(allBooks.map((b) => [b.slug, b]));
const genreFreq = new Map<string, number>();
for (const slug of allSlugs) {
const book = bookMap.get(slug);
if (!book) continue;
for (const g of parseGenresLocal(book.genres)) {
genreFreq.set(g, (genreFreq.get(g) ?? 0) + 1);
}
}
const topGenres = [...genreFreq.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([g]) => g);
// avg rating given
const avgRatingGiven =
ratingRows.length > 0
? Math.round((ratingRows.reduce((s, r) => s + r.rating, 0) / ratingRows.length) * 10) / 10
: 0;
// reading streak: count consecutive calendar days (UTC) with a progress update
const days = new Set(
progressRows
.filter((r) => r.updated)
.map((r) => r.updated.slice(0, 10))
);
let streak = 0;
const today = new Date();
for (let i = 0; i < 365; i++) {
const d = new Date(today);
d.setUTCDate(d.getUTCDate() - i);
if (days.has(d.toISOString().slice(0, 10))) streak++;
else if (i > 0) break; // gap — stop
}
return {
totalChaptersRead: progressRows.length,
booksReading: shelfCounts.reading,
booksCompleted: shelfCounts.completed,
booksPlanToRead: shelfCounts.plan_to_read,
booksDropped: shelfCounts.dropped,
topGenres,
avgRatingGiven,
streak
};
}

View File

@@ -9,12 +9,16 @@
* Product IDs (Polar dashboard):
* Monthly : 1376fdf5-b6a9-492b-be70-7c905131c0f9
* Annual : b6190307-79aa-4905-80c8-9ed941378d21
*
* Webhook event data shapes (Polar v1 API):
* subscription.* → data.customer_id, data.product_id, data.status, data.customer.email
* order.created → data.customer_id, data.product_id, data.customer.email, data.billing_reason
*/
import { createHmac, timingSafeEqual } from 'node:crypto';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
import { getUserById, getUserByPolarCustomerId, patchUser } from '$lib/server/pocketbase';
import { getUserByPolarCustomerId, patchUser } from '$lib/server/pocketbase';
export const POLAR_PRO_PRODUCT_IDS = new Set([
'1376fdf5-b6a9-492b-be70-7c905131c0f9', // monthly
@@ -55,41 +59,69 @@ export function verifyPolarWebhook(rawBody: string, signatureHeader: string): bo
// ─── Subscription event handler ───────────────────────────────────────────────
interface PolarCustomer {
email?: string;
external_id?: string; // our app_users.id if set on the customer
}
interface PolarSubscription {
id: string;
status: string; // "active" | "canceled" | "past_due" | "unpaid" | "incomplete" | ...
status: string; // "active" | "canceled" | "past_due" | "unpaid" | ...
product_id: string;
customer_id: string;
customer_email?: string;
user_id?: string; // Polar user id (not our user id)
customer?: PolarCustomer; // nested object — email lives here
}
/**
* Resolve the app_user for a Polar customer.
* Priority: polar_customer_id → email → customer.external_id (our user ID)
*/
async function resolveUser(customer_id: string, customer?: PolarCustomer) {
const { getUserByEmail, getUserById } = await import('$lib/server/pocketbase');
// 1. By stored polar_customer_id (fastest on repeat events)
const byCustomerId = await getUserByPolarCustomerId(customer_id).catch(() => null);
if (byCustomerId) return byCustomerId;
// 2. By email (most common first-time path)
const email = customer?.email;
if (email) {
const byEmail = await getUserByEmail(email).catch(() => null);
if (byEmail) return byEmail;
}
// 3. By external_id = our user ID (if set via Polar API on customer creation)
const externalId = customer?.external_id;
if (externalId) {
const byId = await getUserById(externalId).catch(() => null);
if (byId) return byId;
}
return null;
}
/**
* Handle a Polar subscription event.
* Finds the matching app_user by email and updates role + polar fields.
* Finds the matching app_user and updates role + polar fields.
*/
export async function handleSubscriptionEvent(
eventType: string,
subscription: PolarSubscription
): Promise<void> {
const { id: subId, status, product_id, customer_id, customer_email } = subscription;
const { id: subId, status, product_id, customer_id, customer } = subscription;
log.info('polar', 'subscription event', { eventType, subId, status, product_id, customer_email });
log.info('polar', 'subscription event', {
eventType, subId, status, product_id,
customer_email: customer?.email
});
if (!customer_email) {
log.warn('polar', 'subscription event missing customer_email — cannot match user', { subId });
return;
}
// Find user by their polar_customer_id first (faster on repeat events), then by email
let user = await getUserByPolarCustomerId(customer_id).catch(() => null);
if (!user) {
const { getUserByEmail } = await import('$lib/server/pocketbase');
user = await getUserByEmail(customer_email).catch(() => null);
}
const user = await resolveUser(customer_id, customer);
if (!user) {
log.warn('polar', 'no app_user found for polar customer', { customer_email, customer_id });
log.warn('polar', 'no app_user found for polar customer', {
customer_email: customer?.email,
customer_id
});
return;
}
@@ -103,5 +135,60 @@ export async function handleSubscriptionEvent(
polar_subscription_id: isActive ? subId : ''
});
log.info('polar', 'user role updated', { userId: user.id, username: user.username, newRole, status });
log.info('polar', 'user role updated', {
userId: user.id, username: user.username, newRole, status
});
}
// ─── Order event handler ──────────────────────────────────────────────────────
interface PolarOrder {
id: string;
status: string;
billing_reason: string; // "purchase" | "subscription_create" | "subscription_cycle" | "subscription_update"
product_id: string | null;
customer_id: string;
subscription_id: string | null;
customer?: PolarCustomer;
}
/**
* Handle order.created — used for initial subscription purchases.
* We only act on subscription_create billing_reason to avoid double-processing
* (subscription.active will also fire, but this ensures we catch edge cases).
*/
export async function handleOrderCreated(order: PolarOrder): Promise<void> {
const { id: orderId, billing_reason, product_id, customer_id, customer } = order;
log.info('polar', 'order.created', { orderId, billing_reason, product_id, customer_email: customer?.email });
// Only handle new subscription purchases here; renewals are handled by subscription.updated
if (billing_reason !== 'purchase' && billing_reason !== 'subscription_create') {
log.debug('polar', 'order.created — skipping non-purchase billing_reason', { billing_reason });
return;
}
if (!product_id || !POLAR_PRO_PRODUCT_IDS.has(product_id)) {
log.debug('polar', 'order.created — product not a pro product', { product_id });
return;
}
const user = await resolveUser(customer_id, customer);
if (!user) {
log.warn('polar', 'order.created — no app_user found', {
customer_email: customer?.email, customer_id
});
return;
}
// Only upgrade if not already pro/admin — subscription.active will do a full sync too
if (user.role !== 'pro' && user.role !== 'admin') {
await patchUser(user.id, {
role: 'pro',
polar_customer_id: customer_id
});
log.info('polar', 'order.created — user upgraded to pro', {
userId: user.id, username: user.username
});
}
}

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.
@@ -155,6 +170,23 @@
audioStore.seekRequest = null;
});
// Sleep timer — fires once when time is up
$effect(() => {
const until = audioStore.sleepUntil;
if (!until) return;
const ms = until - Date.now();
if (ms <= 0) {
audioStore.sleepUntil = 0;
if (audioStore.isPlaying) audioStore.toggleRequest++;
return;
}
const id = setTimeout(() => {
audioStore.sleepUntil = 0;
if (audioStore.isPlaying) audioStore.toggleRequest++;
}, ms);
return () => clearTimeout(id);
});
// ── Save audio time on pause/end (debounced 2s) ─────────────────────────
let audioTimeSaveTimer = 0;
function saveAudioTime() {
@@ -257,6 +289,12 @@
onended={() => {
audioStore.isPlaying = false;
saveAudioTime();
// If sleep-after-chapter is set, just pause instead of navigating
if (audioStore.sleepAfterChapter) {
audioStore.sleepAfterChapter = false;
// Don't navigate just let it end. Audio is already stopped (ended).
return;
}
if (audioStore.autoNext && audioStore.nextChapter !== null && audioStore.slug) {
// Capture values synchronously before any async work — the AudioPlayer
// component will unmount during navigation, but we've already read what
@@ -302,21 +340,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 +452,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 +525,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 +544,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 +722,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,8 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
if (locals.user?.role !== 'admin') {
redirect(302, '/');
}
};

View File

@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
redirect(302, '/admin/scrape');
};

View File

@@ -1,6 +1,6 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { loginUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase';
import { loginUser, mergeSessionProgress, upsertUserSession } from '$lib/server/pocketbase';
import { createAuthToken } from '../../../../hooks.server';
import { log } from '$lib/server/logger';
import { randomBytes } from 'node:crypto';
@@ -48,16 +48,20 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
log.warn('api/auth/login', 'mergeSessionProgress failed (non-fatal)', { err: String(e) })
);
const authSessionId = randomBytes(16).toString('hex');
const candidateSessionId = randomBytes(16).toString('hex');
const userAgent = request.headers.get('user-agent') ?? '';
const ip =
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
request.headers.get('x-real-ip') ??
'';
createUserSession(user.id, authSessionId, userAgent, ip).catch((e) =>
log.warn('api/auth/login', 'createUserSession failed (non-fatal)', { err: String(e) })
);
let authSessionId = candidateSessionId;
try {
({ authSessionId } = await upsertUserSession(user.id, candidateSessionId, userAgent, ip));
} catch (e) {
log.warn('api/auth/login', 'upsertUserSession failed (non-fatal)', { err: String(e) });
}
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);

View File

@@ -0,0 +1,106 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { env } from '$env/dynamic/private';
import { log } from '$lib/server/logger';
import { getUserByUsername } from '$lib/server/pocketbase';
const POLAR_API_BASE = 'https://api.polar.sh';
const PRICE_IDS: Record<string, string> = {
monthly: '9c0eea36-4f4a-4fd6-970b-d176588d4771',
annual: '5a5be04e-f252-4a30-8f8b-858b40ec33e4'
};
/**
* POST /api/checkout
* Body: { product: 'monthly' | 'annual' }
*
* Creates a Polar server-side checkout session with:
* - external_customer_id = locals.user.id (so webhooks can match back to us)
* - customer_email locked to the logged-in user's email (email field disabled in UI)
* - allow_discount_codes: true
* - success_url redirects to /profile?subscribed=1
*
* Returns: { url: string }
*/
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) error(401, 'Not authenticated');
const apiToken = env.POLAR_API_TOKEN;
if (!apiToken) {
log.error('checkout', 'POLAR_API_TOKEN not set');
error(500, 'Checkout unavailable');
}
let product: string;
try {
const body = await request.json() as { product?: unknown };
product = String(body?.product ?? '');
} catch {
error(400, 'Invalid request body');
}
const priceId = PRICE_IDS[product];
if (!priceId) {
error(400, `Unknown product: ${product}. Use 'monthly' or 'annual'.`);
}
// Fetch the user's email from PocketBase (not in the auth token)
let email: string | null = null;
try {
const record = await getUserByUsername(locals.user.username);
email = record?.email ?? null;
} catch (e) {
log.warn('checkout', 'failed to fetch user email (non-fatal)', { err: String(e) });
}
// Create a server-side checkout session on Polar
// https://docs.polar.sh/api-reference/checkouts/create
const payload = {
product_price_id: priceId,
allow_discount_codes: true,
success_url: 'https://libnovel.cc/profile?subscribed=1',
customer_external_id: locals.user.id,
...(email ? { customer_email: email } : {})
};
log.info('checkout', 'creating polar checkout session', {
userId: locals.user.id,
product,
email: email ?? '(none)'
});
const res = await fetch(`${POLAR_API_BASE}/v1/checkouts/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiToken}`
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const text = await res.text().catch(() => '');
log.error('checkout', 'polar checkout creation failed', {
status: res.status,
body: text.slice(0, 500)
});
error(502, 'Failed to create checkout session');
}
const data = await res.json() as { url?: string; id?: string };
const checkoutUrl = data?.url;
if (!checkoutUrl) {
log.error('checkout', 'polar response missing url', { data: JSON.stringify(data).slice(0, 200) });
error(502, 'Invalid checkout response from Polar');
}
log.info('checkout', 'checkout session created', {
userId: locals.user.id,
checkoutId: data?.id,
product
});
return json({ url: checkoutUrl });
};

View File

@@ -0,0 +1,39 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { upsertDiscoveryVote, clearDiscoveryVotes, undoDiscoveryVote, 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 });
};
// DELETE /api/discover/vote → clear all (deck reset)
// DELETE /api/discover/vote?slug=... → undo single vote
export const DELETE: RequestHandler = async ({ url, locals }) => {
const slug = url.searchParams.get('slug');
try {
if (slug) {
await undoDiscoveryVote(locals.sessionId, slug, locals.user?.id);
} else {
await clearDiscoveryVotes(locals.sessionId, locals.user?.id);
}
} catch {
error(500, 'Failed to clear votes');
}
return json({ ok: true });
};

View File

@@ -0,0 +1,29 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { backendFetch } from '$lib/server/scraper';
export const GET: RequestHandler = async ({ params, url }) => {
const { slug } = params;
const from = url.searchParams.get('from');
const to = url.searchParams.get('to');
const qs = new URLSearchParams();
if (from) qs.set('from', from);
if (to) qs.set('to', to);
const query = qs.size ? `?${qs}` : '';
const res = await backendFetch(`/api/export/${encodeURIComponent(slug)}${query}`);
if (!res.ok) {
const text = await res.text().catch(() => '');
error(res.status as Parameters<typeof error>[0], text || 'Export failed');
}
const bytes = await res.arrayBuffer();
return new Response(bytes, {
headers: {
'Content-Type': 'application/epub+zip',
'Content-Disposition': `attachment; filename="${slug}.epub"`
}
});
};

View File

@@ -1,6 +1,7 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { saveBook, unsaveBook } from '$lib/server/pocketbase';
import { saveBook, unsaveBook, updateBookShelf } from '$lib/server/pocketbase';
import type { ShelfName } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
@@ -32,3 +33,17 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
}
return json({ ok: true });
};
/**
* PATCH /api/library/[slug]
* Update the shelf category for a saved book.
*/
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
const { slug } = params;
const body = await request.json().catch(() => null);
const shelf = body?.shelf ?? '';
const VALID = ['', 'plan_to_read', 'completed', 'dropped'];
if (!VALID.includes(shelf)) error(400, 'invalid shelf');
await updateBookShelf(locals.sessionId, slug, shelf as ShelfName, locals.user?.id);
return json({ ok: true });
};

View File

@@ -0,0 +1,24 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getBookRating, getBookAvgRating, setBookRating } from '$lib/server/pocketbase';
export const GET: RequestHandler = async ({ params, locals }) => {
const { slug } = params;
const [userRating, avg] = await Promise.all([
getBookRating(locals.sessionId, slug, locals.user?.id),
getBookAvgRating(slug)
]);
return json({ userRating, avg: avg.avg, count: avg.count });
};
export const POST: RequestHandler = async ({ params, request, locals }) => {
const { slug } = params;
const body = await request.json().catch(() => null);
const rating = body?.rating;
if (typeof rating !== 'number' || rating < 1 || rating > 5) {
error(400, 'rating must be 15');
}
await setBookRating(locals.sessionId, slug, rating, locals.user?.id);
const avg = await getBookAvgRating(slug);
return json({ ok: true, avg: avg.avg, count: avg.count });
};

View File

@@ -1,12 +1,20 @@
import type { RequestHandler } from './$types';
import { log } from '$lib/server/logger';
import { verifyPolarWebhook, handleSubscriptionEvent } from '$lib/server/polar';
import { verifyPolarWebhook, handleSubscriptionEvent, handleOrderCreated } from '$lib/server/polar';
/**
* POST /api/webhooks/polar
*
* Receives Polar subscription lifecycle events and syncs user roles in PocketBase.
* Signature is verified via HMAC-SHA256 before any processing.
*
* Handled events:
* subscription.created — new subscription (status may be "active" or "trialing")
* subscription.active — subscription became active (e.g. after payment)
* subscription.updated — catch-all: cancellations, renewals, plan changes
* subscription.canceled — cancel_at_period_end=true, still active until period end
* subscription.revoked — access ended, downgrade to free
* order.created — purchase / subscription_create: fast-path upgrade
*/
export const POST: RequestHandler = async ({ request }) => {
const rawBody = await request.text();
@@ -30,14 +38,15 @@ export const POST: RequestHandler = async ({ request }) => {
try {
switch (type) {
case 'subscription.created':
case 'subscription.active':
case 'subscription.updated':
case 'subscription.canceled':
case 'subscription.revoked':
await handleSubscriptionEvent(type, data as unknown as Parameters<typeof handleSubscriptionEvent>[1]);
break;
case 'order.created':
// One-time purchases — no role change needed for now
log.info('polar', 'order.created (no action)', { orderId: data.id });
await handleOrderCreated(data as unknown as Parameters<typeof handleOrderCreated>[0]);
break;
default:

View File

@@ -25,7 +25,7 @@ import {
linkOAuthToUser
} from '$lib/server/pocketbase';
import { createAuthToken } from '../../../../hooks.server';
import { createUserSession, touchUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
import { upsertUserSession, mergeSessionProgress } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
type Provider = 'google' | 'github';
@@ -159,7 +159,7 @@ function deriveUsername(name: string, email: string): string {
// ─── Handler ──────────────────────────────────────────────────────────────────
export const GET: RequestHandler = async ({ params, url, cookies, locals }) => {
export const GET: RequestHandler = async ({ params, url, cookies, locals, request }) => {
const provider = params.provider as Provider;
if (provider !== 'google' && provider !== 'github') {
error(404, 'Unknown OAuth provider');
@@ -226,21 +226,19 @@ export const GET: RequestHandler = async ({ params, url, cookies, locals }) => {
log.warn('oauth', 'mergeSessionProgress failed (non-fatal)', { err: String(err) })
);
// ── Create session + auth cookie ──────────────────────────────────────────
// ── Create / reuse session + auth cookie ─────────────────────────────────
const userAgent = request.headers.get('user-agent') ?? '';
const ip =
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
request.headers.get('x-real-ip') ??
'';
const candidateSessionId = randomBytes(16).toString('hex');
let authSessionId: string;
// Reuse existing session if the user is already logged in as the same user
if (locals.user?.id === user.id && locals.user?.authSessionId) {
authSessionId = locals.user.authSessionId;
// Just touch the existing session to update last_seen
touchUserSession(authSessionId).catch(() => {});
} else {
authSessionId = randomBytes(16).toString('hex');
const userAgent = ''; // not available in RequestHandler — omit
const ip = '';
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
log.warn('oauth', 'createUserSession failed (non-fatal)', { err: String(err) })
);
try {
({ authSessionId } = await upsertUserSession(user.id, candidateSessionId, userAgent, ip));
} catch (err) {
log.warn('oauth', 'upsertUserSession failed (non-fatal)', { err: String(err) });
authSessionId = candidateSessionId;
}
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);

View File

@@ -1,16 +1,18 @@
import type { PageServerLoad } from './$types';
import { getBooksBySlugs, allProgress, getSavedSlugs } from '$lib/server/pocketbase';
import { getBooksBySlugs, allProgress, getSavedSlugs, getShelfMap } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import type { Book } from '$lib/server/pocketbase';
export const load: PageServerLoad = async ({ locals }) => {
let progressList: Awaited<ReturnType<typeof allProgress>> = [];
let savedSlugs: Set<string> = new Set();
let shelfMap: Record<string, string> = {};
try {
[progressList, savedSlugs] = await Promise.all([
[progressList, savedSlugs, shelfMap] = await Promise.all([
allProgress(locals.sessionId, locals.user?.id),
getSavedSlugs(locals.sessionId, locals.user?.id)
getSavedSlugs(locals.sessionId, locals.user?.id),
getShelfMap(locals.sessionId, locals.user?.id)
]);
} catch (e) {
log.error('books', 'failed to load library data', { err: String(e) });
@@ -46,6 +48,7 @@ export const load: PageServerLoad = async ({ locals }) => {
return {
books: [...withProgress, ...savedOnly],
progressMap,
savedSlugs: [...savedSlugs]
savedSlugs: [...savedSlugs],
shelfMap
};
};

View File

@@ -14,6 +14,32 @@
return [];
}
}
type Shelf = '' | 'plan_to_read' | 'completed' | 'dropped';
let activeShelf = $state<Shelf | 'all'>('all');
const shelfLabels: Record<string, string> = {
all: 'All',
'': 'Reading',
plan_to_read: 'Plan to Read',
completed: 'Completed',
dropped: 'Dropped'
};
const shelfMap = $derived(data.shelfMap as Record<string, string>);
const filteredBooks = $derived(
activeShelf === 'all'
? data.books
: data.books.filter((b) => (shelfMap[b.slug] ?? '') === activeShelf)
);
const shelfCounts = $derived({
all: data.books.length,
'': data.books.filter((b) => (shelfMap[b.slug] ?? '') === '').length,
plan_to_read: data.books.filter((b) => shelfMap[b.slug] === 'plan_to_read').length,
completed: data.books.filter((b) => shelfMap[b.slug] === 'completed').length,
dropped: data.books.filter((b) => shelfMap[b.slug] === 'dropped').length,
});
</script>
<svelte:head>
@@ -37,10 +63,29 @@
</p>
</div>
{:else}
<!-- Shelf tabs -->
<div class="flex gap-1 flex-wrap mb-4">
{#each (['all', '', 'plan_to_read', 'completed', 'dropped'] as const) as shelf}
{#if shelfCounts[shelf] > 0 || shelf === 'all'}
<button
type="button"
onclick={() => (activeShelf = shelf)}
class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors
{activeShelf === shelf
? 'bg-(--color-brand) text-(--color-surface)'
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text) border border-(--color-border)'}"
>
{shelfLabels[shelf]}{shelfCounts[shelf] !== data.books.length || shelf === 'all' ? ` (${shelfCounts[shelf]})` : ''}
</button>
{/if}
{/each}
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{#each data.books as book}
{#each filteredBooks as book}
{@const lastChapter = data.progressMap[book.slug]}
{@const genres = parseGenres(book.genres)}
{@const bookShelf = shelfMap[book.slug] ?? ''}
<a
href="/books/{book.slug}"
class="group flex flex-col rounded-lg overflow-hidden bg-(--color-surface-2) hover:bg-(--color-surface-3) transition-colors border border-(--color-border) hover:border-zinc-500"
@@ -85,6 +130,11 @@
</span>
{/if}
</div>
{#if bookShelf && activeShelf === 'all'}
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted) self-start">
{shelfLabels[bookShelf] ?? bookShelf}
</span>
{/if}
{#if genres.length > 0}
<div class="flex flex-wrap gap-1 mt-1">

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getBook, listChapterIdx, getProgress, isBookSaved, countReadersThisWeek } from '$lib/server/pocketbase';
import { getBook, listChapterIdx, getProgress, isBookSaved, countReadersThisWeek, getBookRating, getBookAvgRating } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
import { backendFetch, type BookPreviewResponse } from '$lib/server/scraper';
@@ -15,13 +15,15 @@ export const load: PageServerLoad = async ({ params, locals }) => {
if (book) {
// Book is in the library — normal path
let chapters, progress, saved, readersThisWeek;
let chapters, progress, saved, readersThisWeek, userRating, ratingAvg;
try {
[chapters, progress, saved, readersThisWeek] = await Promise.all([
[chapters, progress, saved, readersThisWeek, userRating, ratingAvg] = await Promise.all([
listChapterIdx(slug),
getProgress(locals.sessionId, slug, locals.user?.id),
isBookSaved(locals.sessionId, slug, locals.user?.id),
countReadersThisWeek(slug)
countReadersThisWeek(slug),
getBookRating(locals.sessionId, slug, locals.user?.id),
getBookAvgRating(slug)
]);
} catch (e) {
log.error('books', 'failed to load book page data', { slug, err: String(e) });
@@ -35,6 +37,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
saved,
lastChapter: progress?.chapter ?? null,
readersThisWeek,
userRating: userRating ?? 0,
ratingAvg: ratingAvg ?? { avg: 0, count: 0 },
isAdmin: locals.user?.role === 'admin',
isLoggedIn: !!locals.user,
currentUserId: locals.user?.id ?? '',
@@ -58,6 +62,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
inLib: false,
saved: false,
lastChapter: null,
userRating: 0,
ratingAvg: { avg: 0, count: 0 },
isAdmin: locals.user?.role === 'admin',
isLoggedIn: !!locals.user,
currentUserId: locals.user?.id ?? '',
@@ -95,6 +101,8 @@ export const load: PageServerLoad = async ({ params, locals }) => {
inLib: true,
saved: false,
lastChapter: null,
userRating: 0,
ratingAvg: { avg: 0, count: 0 },
isAdmin: locals.user?.role === 'admin',
isLoggedIn: !!locals.user,
currentUserId: locals.user?.id ?? '',

View File

@@ -3,7 +3,9 @@
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types';
import CommentsSection from '$lib/components/CommentsSection.svelte';
import StarRating from '$lib/components/StarRating.svelte';
import * as m from '$lib/paraglide/messages.js';
import type { ShelfName } from '$lib/server/pocketbase';
let { data }: { data: PageData } = $props();
@@ -17,6 +19,37 @@
let saved = $state(untrack(() => data.saved));
let saving = $state(false);
// ── Ratings ───────────────────────────────────────────────────────────────
let userRating = $state(data.userRating ?? 0);
let ratingAvg = $state(data.ratingAvg ?? { avg: 0, count: 0 });
async function rate(r: number) {
userRating = r;
try {
const res = await fetch(`/api/ratings/${encodeURIComponent(data.book?.slug ?? '')}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating: r })
});
if (res.ok) {
const body = await res.json();
ratingAvg = { avg: body.avg, count: body.count };
}
} catch { /* ignore */ }
}
// ── Shelf ─────────────────────────────────────────────────────────────────
let currentShelf = $state<ShelfName>('');
async function setShelf(shelf: ShelfName) {
currentShelf = shelf;
await fetch(`/api/library/${encodeURIComponent(data.book?.slug ?? '')}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shelf })
});
}
async function toggleSave() {
if (saving || !data.book) return;
saving = true;
@@ -286,6 +319,31 @@
</button>
{/if}
</div>
<!-- Ratings + shelf — desktop -->
<div class="hidden sm:flex items-center gap-3 flex-wrap mt-1">
<StarRating
rating={userRating}
avg={ratingAvg.avg}
count={ratingAvg.count}
onrate={rate}
size="md"
/>
{#if saved}
<div class="relative">
<select
value={currentShelf}
onchange={(e) => setShelf((e.currentTarget as HTMLSelectElement).value as ShelfName)}
class="bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-sm text-(--color-muted) focus:outline-none focus:ring-2 focus:ring-(--color-brand) cursor-pointer"
>
<option value="">Reading</option>
<option value="plan_to_read">Plan to Read</option>
<option value="completed">Completed</option>
<option value="dropped">Dropped</option>
</select>
</div>
{/if}
</div>
</div>
</div>
@@ -346,10 +404,55 @@
{/if}
</button>
{/if}
<!-- Ratings + shelf — mobile -->
<div class="flex sm:hidden items-center gap-3 flex-wrap mt-1">
<StarRating
rating={userRating}
avg={ratingAvg.avg}
count={ratingAvg.count}
onrate={rate}
size="sm"
/>
{#if saved}
<div class="relative">
<select
value={currentShelf}
onchange={(e) => setShelf((e.currentTarget as HTMLSelectElement).value as ShelfName)}
class="bg-(--color-surface-2) border border-(--color-border) rounded-lg px-3 py-1.5 text-sm text-(--color-muted) focus:outline-none focus:ring-2 focus:ring-(--color-brand) cursor-pointer"
>
<option value="">Reading</option>
<option value="plan_to_read">Plan to Read</option>
<option value="completed">Completed</option>
<option value="dropped">Dropped</option>
</select>
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════ Download row ══ -->
{#if data.inLib && chapterList.length > 0}
<div class="flex items-center gap-3 border border-(--color-border) rounded-xl px-4 py-3 mb-4">
<svg class="w-4 h-4 text-(--color-muted) flex-shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-(--color-text)">Download</p>
<p class="text-xs text-(--color-muted)">All {chapterList.length} chapters as EPUB</p>
</div>
<a
href="/api/export/{book.slug}"
download="{book.slug}.epub"
class="px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-sm font-medium text-(--color-muted) hover:text-(--color-text) hover:border-zinc-500 transition-colors flex-shrink-0"
>
.epub
</a>
</div>
{/if}
<!-- ══════════════════════════════════════════════════ Chapters row ══ -->
<div class="flex flex-col divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden mb-6">
<a

View File

@@ -0,0 +1,17 @@
import type { PageServerLoad } from './$types';
import { getBooksForDiscovery, getVotedBooks } 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, votedBooks] = await Promise.all([
getBooksForDiscovery(locals.sessionId, locals.user?.id, prefs).catch(() => []),
getVotedBooks(locals.sessionId, locals.user?.id).catch(() => [])
]);
return { books, votedBooks };
};

View File

@@ -0,0 +1,736 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import type { PageData } from './$types';
import type { Book, VotedBook } 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 activeTab = $state<'discover' | 'history'>('discover');
let votedBooks = $state<VotedBook[]>(data.votedBooks ?? []);
// Keep in sync if server data refreshes
$effect(() => {
votedBooks = data.votedBooks ?? [];
});
async function undoVote(slug: string) {
// Optimistic update
votedBooks = votedBooks.filter((v) => v.slug !== slug);
await fetch(`/api/discover/vote?slug=${encodeURIComponent(slug)}`, { method: 'DELETE' });
}
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);
// ── Card entry animation (prevents pop-to-full-size after swipe) ─────────────
let cardEntering = $state(false);
let entryTransition = $state(false);
let entryCleanup: ReturnType<typeof setTimeout> | null = null;
function startEntryAnimation() {
if (entryCleanup) clearTimeout(entryCleanup);
cardEntering = true;
entryTransition = true;
requestAnimationFrame(() => {
cardEntering = false;
entryCleanup = setTimeout(() => { entryTransition = false; }, 400);
});
}
function cancelEntryAnimation() {
if (entryCleanup) { clearTimeout(entryCleanup); entryCleanup = null; }
cardEntering = false;
entryTransition = false;
}
const activeTransform = $derived(
cardEntering
? 'scale(0.95) translateY(13px)'
: `translateX(${offsetX}px) translateY(${offsetY}px) rotate(${rotation}deg)`
);
const activeTransition = $derived(
isDragging
? 'none'
: (transitioning || entryTransition)
? 'transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275)'
: 'none'
);
function onPointerDown(e: PointerEvent) {
cancelEntryAnimation();
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}`);
} else {
startEntryAnimation();
}
}
async function resetDeck() {
await fetch('/api/discover/vote', { method: 'DELETE' });
votedBooks = [];
idx = 0;
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}
{@const previewBook = currentBook!}
<div
class="fixed inset-0 z-40 flex items-end sm:items-center justify-center p-4"
role="presentation"
onclick={() => (showPreview = false)}
onkeydown={(e) => { if (e.key === 'Escape') 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"
role="dialog"
aria-modal="true"
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" />
<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">{previewBook.title}</h3>
{#if previewBook.author}
<p class="text-sm text-(--color-muted) mb-3">{previewBook.author}</p>
{/if}
{#if previewBook.summary}
<p class="text-sm text-(--color-muted) leading-relaxed line-clamp-5 mb-4">{previewBook.summary}</p>
{/if}
<div class="flex flex-wrap gap-2 mb-5">
{#each parseBookGenres(previewBook.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 previewBook.status}
<span class="text-xs px-2 py-0.5 rounded-full bg-(--color-surface-3) text-(--color-text)">{previewBook.status}</span>
{/if}
{#if previewBook.total_chapters}
<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>
</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>
<!-- Tab switcher -->
<div class="flex gap-1 bg-(--color-surface-2) rounded-xl p-1 w-full max-w-sm border border-(--color-border) mb-4">
<button
type="button"
onclick={() => (activeTab = 'discover')}
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
{activeTab === 'discover' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
Discover
</button>
<button
type="button"
onclick={() => (activeTab = 'history')}
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
{activeTab === 'history' ? 'bg-(--color-surface-3) text-(--color-text)' : 'text-(--color-muted) hover:text-(--color-text)'}"
>
History {#if votedBooks.length}({votedBooks.length}){/if}
</button>
</div>
{#if activeTab === 'discover'}
{#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}
{@const book = currentBook!}
<!-- 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: {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>
</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">{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>
<!-- 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}
{/if}
{#if activeTab === 'history'}
<div class="w-full max-w-sm space-y-2">
{#if !votedBooks.length}
<p class="text-center text-(--color-muted) text-sm py-12">No votes yet — start swiping!</p>
{: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'}
<div class="flex items-center gap-3 bg-(--color-surface-2) rounded-xl border border-(--color-border) p-3">
<!-- Cover thumbnail -->
{#if v.book?.cover}
<img src={v.book.cover} alt="" class="w-10 h-14 rounded-md object-cover flex-shrink-0" />
{:else}
<div class="w-10 h-14 rounded-md bg-(--color-surface-3) flex-shrink-0"></div>
{/if}
<!-- Info -->
<div class="flex-1 min-w-0">
<a href="/books/{v.slug}" class="text-sm font-semibold text-(--color-text) hover:text-(--color-brand) transition-colors line-clamp-1">
{v.book?.title ?? v.slug}
</a>
{#if v.book?.author}
<p class="text-xs text-(--color-muted) truncate">{v.book.author}</p>
{/if}
<span class="text-xs font-medium {actionColor}">{actionLabel}</span>
</div>
<!-- Undo button -->
<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}"
>
<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"
>
Clear all history
</button>
{/if}
</div>
{/if}
</div>

View File

@@ -1,6 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
import { listUserSessions, getUserByUsername, getUserStats } from '$lib/server/pocketbase';
import { resolveAvatarUrl } from '$lib/server/minio';
import { log } from '$lib/server/logger';
@@ -10,24 +10,40 @@ export const load: PageServerLoad = async ({ locals }) => {
}
let sessions: Awaited<ReturnType<typeof listUserSessions>> = [];
try {
sessions = await listUserSessions(locals.user.id);
} catch (e) {
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
}
let email: string | null = null;
let polarCustomerId: string | null = null;
let stats: Awaited<ReturnType<typeof getUserStats>> | null = null;
// Fetch avatar — MinIO first, fall back to OAuth provider picture
let avatarUrl: string | null = null;
try {
const record = await getUserByUsername(locals.user.username);
avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url);
email = record?.email ?? null;
polarCustomerId = record?.polar_customer_id ?? null;
} catch (e) {
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
}
try {
[sessions, stats] = await Promise.all([
listUserSessions(locals.user.id),
getUserStats(locals.sessionId, locals.user.id)
]);
} catch (e) {
log.warn('profile', 'load failed (non-fatal)', { err: String(e) });
}
return {
user: locals.user,
avatarUrl,
email,
polarCustomerId,
stats: stats ?? {
totalChaptersRead: 0, booksReading: 0, booksCompleted: 0,
booksPlanToRead: 0, booksDropped: 0, topGenres: [],
avgRatingGiven: 0, streak: 0
},
sessions: sessions.map((s) => ({
id: s.id,
user_agent: s.user_agent,

View File

@@ -4,12 +4,46 @@
import type { PageData, ActionData } from './$types';
import { audioStore } from '$lib/audio.svelte';
import { browser } from '$app/environment';
import { page } from '$app/state';
import type { Voice } from '$lib/types';
import * as m from '$lib/paraglide/messages.js';
let { data, form }: { data: PageData; form: ActionData } = $props();
// ── Polar checkout ───────────────────────────────────────────────────────────
// Customer portal: always link to the org portal
const manageUrl = `https://polar.sh/libnovel/portal`;
let checkoutLoading = $state<'monthly' | 'annual' | null>(null);
let checkoutError = $state('');
async function startCheckout(product: 'monthly' | 'annual') {
checkoutLoading = product;
checkoutError = '';
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product })
});
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { message?: string };
checkoutError = body.message ?? `Checkout failed (${res.status}). Please try again.`;
return;
}
const { url } = await res.json() as { url: string };
window.location.href = url;
} catch {
checkoutError = 'Network error. Please try again.';
} finally {
checkoutLoading = null;
}
}
// ── Avatar ───────────────────────────────────────────────────────────────────
// Show a welcome banner when Polar redirects back with ?subscribed=1
const justSubscribed = $derived(browser && page.url.searchParams.get('subscribed') === '1');
let avatarUrl = $state<string | null>(untrack(() => data.avatarUrl ?? null));
let avatarUploading = $state(false);
let avatarError = $state('');
@@ -150,6 +184,9 @@
}, 800) as unknown as number;
});
// ── Tab ──────────────────────────────────────────────────────────────────────
let activeTab = $state<'profile' | 'stats'>('profile');
// ── Sessions ─────────────────────────────────────────────────────────────────
type Session = {
id: string;
@@ -218,6 +255,17 @@
<div class="max-w-2xl mx-auto space-y-6 pb-12">
<!-- ── Post-checkout success banner ──────────────────────────────────────── -->
{#if justSubscribed}
<div class="rounded-xl bg-(--color-brand)/10 border border-(--color-brand)/40 px-5 py-4 flex items-start gap-3">
<svg class="w-5 h-5 text-(--color-brand) shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
<div>
<p class="text-sm font-semibold text-(--color-brand)">Welcome to Pro!</p>
<p class="text-sm text-(--color-muted) mt-0.5">Your subscription is being activated. Refresh the page in a moment if the Pro badge doesn't appear yet.</p>
</div>
</div>
{/if}
<!-- ── Profile header ──────────────────────────────────────────────────────── -->
<div class="flex items-center gap-5 pt-2">
<div class="relative shrink-0">
@@ -272,6 +320,23 @@
</div>
</div>
<!-- Tabs -->
<div class="flex gap-1 bg-(--color-surface-2) rounded-xl p-1 border border-(--color-border)">
{#each (['profile', 'stats'] as const) as tab}
<button
type="button"
onclick={() => (activeTab = tab)}
class="flex-1 py-2 rounded-lg text-sm font-medium transition-colors
{activeTab === tab
? 'bg-(--color-surface-3) text-(--color-text) shadow-sm'
: 'text-(--color-muted) hover:text-(--color-text)'}"
>
{tab === 'profile' ? 'Profile' : 'Stats'}
</button>
{/each}
</div>
{#if activeTab === 'profile'}
<!-- ── Subscription ─────────────────────────────────────────────────────────── -->
{#if !data.isPro}
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-6">
@@ -287,17 +352,34 @@
<div class="mt-5 pt-5 border-t border-(--color-border)">
<p class="text-sm font-medium text-(--color-text) mb-1">{m.profile_upgrade_heading()}</p>
<p class="text-sm text-(--color-muted) mb-4">{m.profile_upgrade_desc()}</p>
{#if checkoutError}
<p class="text-sm text-(--color-danger) mb-3">{checkoutError}</p>
{/if}
<div class="flex flex-wrap gap-3">
<a href="https://buy.polar.sh/libnovel/1376fdf5-b6a9-492b-be70-7c905131c0f9" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors">
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
<button
type="button"
onclick={() => startCheckout('monthly')}
disabled={checkoutLoading !== null}
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) transition-colors disabled:opacity-60 disabled:cursor-wait">
{#if checkoutLoading === 'monthly'}
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
{:else}
<svg class="w-4 h-4 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zm4.24 16L12 15.45 7.77 18l1.12-4.81-3.73-3.23 4.92-.42L12 5l1.92 4.53 4.92.42-3.73 3.23L16.23 18z"/></svg>
{/if}
{m.profile_upgrade_monthly()}
</a>
<a href="https://buy.polar.sh/libnovel/b6190307-79aa-4905-80c8-9ed941378d21" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors">
{m.profile_upgrade_annual()}
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">33%</span>
</a>
</button>
<button
type="button"
onclick={() => startCheckout('annual')}
disabled={checkoutLoading !== null}
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-(--color-brand) text-(--color-brand) font-semibold text-sm hover:bg-(--color-brand)/10 transition-colors disabled:opacity-60 disabled:cursor-wait">
{#if checkoutLoading === 'annual'}
<svg class="w-4 h-4 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/></svg>
{:else}
{m.profile_upgrade_annual()}
<span class="text-xs font-bold px-1.5 py-0.5 rounded bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30">33%</span>
{/if}
</button>
</div>
</div>
</section>
@@ -307,7 +389,7 @@
<p class="text-sm font-medium text-(--color-text)">{m.profile_pro_active()}</p>
<p class="text-sm text-(--color-muted) mt-0.5">{m.profile_pro_perks()}</p>
</div>
<a href="https://polar.sh/libnovel" target="_blank" rel="noopener noreferrer"
<a href={manageUrl} target="_blank" rel="noopener noreferrer"
class="shrink-0 inline-flex items-center gap-1.5 text-sm font-medium text-(--color-brand) hover:underline">
{m.profile_manage_subscription()}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
@@ -503,5 +585,77 @@
</ul>
{/if}
</section>
{/if}
{#if activeTab === 'stats'}
<div class="space-y-4">
<!-- Reading overview -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Reading Overview</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
{#each [
{ label: 'Chapters Read', value: data.stats.totalChaptersRead, icon: '📖' },
{ label: 'Completed', value: data.stats.booksCompleted, icon: '✅' },
{ label: 'Reading', value: data.stats.booksReading, icon: '📚' },
{ label: 'Plan to Read', value: data.stats.booksPlanToRead, icon: '🔖' },
] as stat}
<div class="bg-(--color-surface-3) rounded-lg p-3 text-center">
<p class="text-2xl font-bold text-(--color-text) tabular-nums">{stat.value}</p>
<p class="text-xs text-(--color-muted) mt-0.5">{stat.label}</p>
</div>
{/each}
</div>
</section>
<!-- Streak + rating -->
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-4">Activity</h2>
<div class="grid grid-cols-2 gap-3">
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-lg p-3">
<div class="w-9 h-9 rounded-full bg-orange-500/15 flex items-center justify-center text-lg flex-shrink-0">🔥</div>
<div>
<p class="text-xl font-bold text-(--color-text) tabular-nums">{data.stats.streak}</p>
<p class="text-xs text-(--color-muted)">day streak</p>
</div>
</div>
<div class="flex items-center gap-3 bg-(--color-surface-3) rounded-lg p-3">
<div class="w-9 h-9 rounded-full bg-yellow-500/15 flex items-center justify-center text-lg flex-shrink-0"></div>
<div>
<p class="text-xl font-bold text-(--color-text) tabular-nums">
{data.stats.avgRatingGiven > 0 ? data.stats.avgRatingGiven.toFixed(1) : '—'}
</p>
<p class="text-xs text-(--color-muted)">avg rating given</p>
</div>
</div>
</div>
</section>
<!-- Top genres -->
{#if data.stats.topGenres.length > 0}
<section class="bg-(--color-surface-2) rounded-xl border border-(--color-border) p-5">
<h2 class="text-sm font-semibold text-(--color-muted) uppercase tracking-wider mb-3">Favourite Genres</h2>
<div class="flex flex-wrap gap-2">
{#each data.stats.topGenres as genre, i}
<span class="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium
{i === 0 ? 'bg-(--color-brand)/20 text-(--color-brand) border border-(--color-brand)/30' : 'bg-(--color-surface-3) text-(--color-text) border border-(--color-border)'}">
{#if i === 0}<span class="text-xs">🏆</span>{/if}
{genre}
</span>
{/each}
</div>
</section>
{/if}
<!-- Dropped books (only if any) -->
{#if data.stats.booksDropped > 0}
<p class="text-xs text-(--color-muted) text-center">
{data.stats.booksDropped} dropped book{data.stats.booksDropped !== 1 ? 's' : ''}
<a href="/books" class="text-(--color-brand) hover:underline">revisit your library</a>
</p>
{/if}
</div>
{/if}
</div>