- 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>
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.
- 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
- 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)
The fixed top bar was hidden behind the main site navbar (z-50).
Replace with an inline 'Admin menu' button at the top of the content.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The admin_nav_* message files generated by paraglide used 0-param inner
functions but called them with inputs, causing 50 svelte-check type errors.
Add _inputs = {} to each inner locale function to match the call signature.
Also adds backend/backend and backend/runner to .gitignore — binaries are
built inside Dockerfile and should never be committed.
The paraglideVitePlugin runs at build time (buildStart hook) and fetches
the inlang plugin from cdn.jsdelivr.net to recompile messages. This:
1. Overwrites messages.js with 'export * as m from ...' unconditionally
2. Causes Rollup SSR tree-shaking to replace all m.*() calls with (void 0)
3. Crashes every page server-side with 'TypeError: (void 0) is not a function'
The plugin is no longer needed: compiled paraglide output is committed to
git and updated via 'npm run paraglide' when messages change. Removing the
plugin lets Vite treat messages.js as a plain static module, keeping all
exports intact through the SSR bundle.
- ui/src/error.html: custom SvelteKit last-resort fallback (replaces
the bare '500 | Internal Error' shown when +error.svelte itself fails)
— branded, auto-refreshes in 20s, book+lightning SVG illustration
- ui/src/routes/+error.svelte: improved with context-aware SVG
illustrations (question mark book for 404, lightning bolt for 5xx),
larger status watermark, and a Retry button on non-404 errors
- caddy/errors/500.html: new static error page matching the 502/503/504
design — served by Caddy when a gateway-level 500 occurs
- Caddyfile: add handle_errors 500 block pointing at /srv/errors/500.html
- caddy/Dockerfile: COPY errors/ into image so static pages are baked in
These files are needed at CI check time. paraglide fetches its plugin
from cdn.jsdelivr.net which is unavailable in the CI environment,
causing compile to produce empty output. Committing the generated
output means CI never needs to recompile them.
paraglide-js fetches its plugin from cdn.jsdelivr.net at compile time.
In CI (no outbound internet access), this silently produces an empty
_index.js, causing svelte-check to fail with 'not a module' errors.
Fix:
- Commit all generated src/lib/paraglide/ files to git (removing
the auto-generated .gitignore that was hiding them)
- Simplify prepare script to only run svelte-kit sync
- Add separate 'npm run paraglide' script for developers to
regenerate when messages/*.json source files change
paraglide-js unconditionally emits 'export * as m from ...' in messages.js
which causes Vite/Rollup SSR to tree-shake all named message imports,
replacing every m.*() call with (void 0)() and crashing every page.
Strip the two offending lines via a Node.js one-liner in the prepare script
so the fix survives every npm ci run in CI.
Also stop tracking messages.js in git since it is always regenerated.
The paraglide messages.js had an extra 'export * as m from ...' line which
caused Rollup/Vite to tree-shake all actual message function imports in the
SSR bundle. Every m.* call compiled to (void 0)(), crashing every page
server-side with TypeError. Removed the duplicate namespace re-export.
Collections without an 'updated' field (books, user_sessions, user_settings,
user_library) were returning 400 because listOne always sent sort=-updated.
Changed default to empty string since we only fetch 1 record (no ordering needed).
- CommentsSection now accepts a chapter prop and scopes comments to that chapter
- Chapter reader page mounts CommentsSection with current chapter number
- Book detail page shows rolling 7-day unique reader count badge
- API GET/POST pass chapter param; pocketbase listComments filters by chapter
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- First continue-reading book becomes a wide hero card with title,
description, genre tags, and a prominent Resume ch.N CTA
- Remaining in-progress books move to a horizontal scroll shelf
- Recently Updated deduplicates by slug; books with multiple new
chapters show a green "+N ch." badge
- Genre discovery strip (horizontal scroll) links to /catalogue?genre=X
- Stats demoted to a subtle two-number footer bar
- All rows use horizontal scroll instead of fixed grids
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three light variants mirroring the existing dark set. All use CSS custom
properties so no component changes are needed. Theme dots in the header
and mobile drawer show a separator between dark/light groups; light-theme
swatches get a subtle ring so they're visible on light backgrounds.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove both "Save settings" buttons; all settings now auto-save with
800ms debounce and show a transient "✓ Saved" indicator
- Apply theme, font family, and font size to context immediately on
change so the preview is live without waiting for the save
- Merge Appearance + Reading settings into a single Preferences card
with dividers — fewer sections, less visual noise
- Pro users see a compact subscription row; free users see upgrade CTAs
- Speed label splits value and units (shows "1.5x" separately in brand
color) for cleaner readout
- Auto-advance toggle gains a subtitle description
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: user_settings table was missing theme, locale, font_family,
font_size columns — PocketBase silently dropped them on every save.
Added the four columns via PocketBase API.
Also:
- listOne now sorts by -updated so the most-recent settings record wins
- PARAGLIDE_LOCALE cookie is now cleared when switching back to English
- pt-BR renamed to pt throughout (messages, inlang settings, validLocales)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sessions not seen in 30+ days are deleted in the background each time
a new session is created. No cron job needed — self-cleaning on login.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Desktop header before: logo · Library · Catalogue · Feedback · ●●● · EN RU ID PT-BR FR · Admin · username · Sign out
Desktop header after: logo · Library · Catalogue · Feedback · ●●● · [🌐 EN ▾] · [avatar ▾]
Changes:
- Theme dots kept but made slightly smaller (3.5 → 3.5, less gap)
- 5 bare language codes replaced with a compact globe + current locale
dropdown (click to expand all 5 options)
- Admin link, username, and Sign out collapsed into a single user-initial
avatar dropdown (Profile / Admin panel / Sign out)
- Click-outside overlay closes any open dropdown
- Mobile drawer: added Theme and Language rows so users can switch both
without opening the desktop header
- Footer locale switcher removed (redundant with header and mobile drawer)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reading content is now publicly accessible without login:
- /catalogue, /books/[slug], /books/[slug]/chapters, /books/[slug]/chapters/[n]
are all public — no login redirect
- /books (personal library), /profile, /admin remain protected
Unauthenticated UX:
- Chapter page: "Audio narration available — Sign in to listen" banner
replaces the audio player for logged-out visitors
- Book detail: bookmark icon links to /login instead of triggering
the save action (both desktop and mobile CTAs)
SEO:
- robots.txt updated: Allow /books/ and /catalogue, Disallow /books (library)
- sitemap.xml now includes /catalogue as primary crawl entry point
i18n: added reader_signin_for_audio, reader_signin_audio_desc,
book_detail_signin_to_save in all 5 languages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix language not persisting after refresh: save locale in user_settings,
set PARAGLIDE_LOCALE cookie from DB preference on server load
- Fix theme change not applying: context setter was mismatched (setTheme vs current)
- Add font family (system/serif/mono) and text size (sm/md/lg/xl) user settings
stored in DB and applied via CSS custom properties
- Add theme color dots and language picker to desktop header for quick access
- Footer locale switcher now saves preference to DB before switching
- Remove change password section (OAuth-only, no password login)
- Fix active sessions piling up: reuse existing session on re-login via OAuth
- Extend speed step cycle to include 2.5× and 3.0× (matching profile slider)
- Replace plain checkbox with modern toggle switch for auto-next setting
- Fix catalogue status labels (ongoing/completed) to use translation keys
- Add font family and text size translation keys to all 5 locale files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Webhook handler verifies HMAC-SHA256 sig and updates user role on
subscription.created / subscription.updated / subscription.revoked
- Audio endpoint gated: free users limited to 3 chapters/day via Valkey
counter; returns 402 {error:'pro_required'} when limit reached
- Translation proxy endpoint enforces 402 for non-pro users
- AudioPlayer.svelte surfaces 402 via onProRequired callback + upgrade banner
- Chapter page shows lock icon + upgrade prompts for gated translation langs
- Profile page: subscription section shows Pro badge + manage link (active)
or monthly/annual checkout buttons (free); isPro resolved fresh from DB
- i18n: 13 new profile_subscription_* keys across all 5 locales
The paraglide output dir has its own .gitignore (ignores everything), so
generated files are never committed. CI ran `npm ci` then `svelte-check`
without ever invoking vite, so $lib/paraglide/server was missing at
type-check time.
Adding `paraglide-js compile` to the prepare script ensures the output is
regenerated during `npm ci` before svelte-kit sync and svelte-check run.
Also adds translation_jobs collection to pb-init-v3.sh.
- LibreTranslate client (chunks on blank lines, ≤4500 chars, 3-goroutine semaphore)
- Runner translation task loop (OTel, heartbeat, MinIO storage)
- PocketBase translation_jobs collection support (create/claim/finish/list)
- Per-chapter language switcher on chapter reader (EN/RU/ID/PT/FR, polls until done)
- Admin /admin/translation page: bulk enqueue form + live-polling jobs table
- New backend routes: POST /api/translation/{slug}/{n}, GET /api/translation/status,
GET /api/translation/{slug}/{n}, GET /api/admin/translation/jobs,
POST /api/admin/translation/bulk
- ListTranslationTasks added to taskqueue.Reader interface + store impl
- All builds and tests pass; svelte-check: 0 errors
Replace runtime Gitea API fetch with fs.readFileSync of releases.json,
which CI writes to ui/static/ before the Docker build context is sent.
Eliminates prod→homelab network dependency for the changelog page.
- Scrape page: replace three large cards + genre card with a compact
bordered table of rows (label + inline controls per action). Visually
much lighter, all controls visible without scrolling.
- Admin sidebar: add Changelog link after Audio.
- New /admin/changelog page: fetches releases from Gitea API and renders
them as a clean list (tag, title, date, body).
Move admin navigation from a two-row tab strip into a persistent left
sidebar with grouped sections (Pages / Tools). Consolidate Scrape and
Audio entries in the global top nav into a single Admin link.
Removed onchange→navigateWithFilters from all three selects — the
immediate navigation on every change was the root cause of both bugs:
1. Filters applied before the user finished selecting all options.
2. Svelte 5 bind:value updates state after onchange fires, so the
navigateWithFilters call read stale values → wrong URL params → no results.
Renamed navigateWithFilters to applyFilters (no overrides arg needed).
Added an amber Apply button next to Reset; selects now only update local
state until the user presses Apply.
PUBLIC_BUILD_VERSION and PUBLIC_BUILD_COMMIT were set only in the builder
stage ENV — they were never re-declared in the runtime stage, so the Node
server started with them undefined and the badge always showed 'dev'.
Fix: re-declare all three ARGs after the second FROM and set runtime ENVs.
Add PUBLIC_BUILD_TIME (ISO timestamp from gitea.event.head_commit.timestamp)
injected via build-arg in release.yaml. Badge now shows e.g.:
v2.3.9+abc1234 · 28 Mar 2026 14:30 UTC
Add resolveAvatarUrl(userId, storedValue) helper that tries MinIO first,
then falls back to the stored HTTP URL for OAuth users (Google/GitHub)
who have never uploaded a custom avatar.
Add getUserById() to pocketbase helpers for batch avatar resolution in
comments. Update all 6 call sites to use the new helper.
- Catalogue filter selects: replace value= initializer with bind:value +
onchange goto() so filters navigate immediately on change (no Apply button)
- Add selected= on each <option> for correct DOM initialisation in Svelte 5
- Build badge: give distinct bg-zinc-800 pill, visible zinc-300/400/500 text
instead of zinc-700/zinc-800 which blended into the footer background
- docker-compose.yml: MEILI_URL in x-infra-env was hardcoded to the local
meilisearch container, ignoring the Doppler MEILI_URL secret entirely.
Changed to "${MEILI_URL:-http://meilisearch:7700}" so prod reads from
https://search.libnovel.cc while local dev keeps the container default.
- ui/+layout.svelte: rename nav + footer label from "Discover" to "Catalogue"
(desktop nav, mobile menu, footer — all three occurrences).
- Add POCKET_TTS_URL env to backend service in docker-compose.yml so
pocket-tts voices appear in the voice selector (Doppler secret existed
but the env var was never passed to the container)
- Fix GetAudioTask PocketBase filter using %q (double-quotes) instead of
single-quoted string, causing the duplicate-task guard to always miss
- Fix AudioPlayer double-POST: GET /api/presign/audio already enqueues
TTS internally on 404; AudioPlayer now skips the redundant POST and
polls directly, eliminating the 500 from the PB unique-key conflict