iOS BookDetailView: replace paginated inline chapter list with a single
tappable 'Chapters' row (showing reading progress) that opens
BookChaptersSheet — a searchable full-screen sheet with jump-to-current.
ChapterReaderView: hide tab bar in reader, swap back/Aa/ToC button order
(Aa left, ToC right, X rightmost), remove mini-player spacer (tab bar
and player are hidden).
HomeView: remove large HeroContinueCard, promote all continue-reading
items into a single horizontal shelf (Apple Books style) with progress
bar below each cover. NavigationLink now goes directly to the chapter.
Web +page.svelte: replace inline paginated chapter list with a compact
'Chapters' row linking to /books/[slug]/chapters. Admin scrape controls
are now a collapsible row inside the same card.
The /api/comments/[id] delete route was never created; the deleteComment helper
in pocketbase.ts existed but was unreachable. Added DELETE /api/comment/[id]
route handler alongside the existing vote route. Updated CommentsSection.svelte
and iOS APIClient to use /api/comment/{id} for both delete and (already fixed)
vote, keeping all comment-mutation endpoints under the singular /api/comment/
prefix to avoid SvelteKit route conflicts with /api/comments/[slug].
- Go scraper: Version/Commit vars in main.go, injected via -ldflags; Server struct + New() updated; GET /health and new GET /api/version expose them
- UI Dockerfile: ARG BUILD_VERSION/BUILD_COMMIT → ENV PUBLIC_BUILD_VERSION/PUBLIC_BUILD_COMMIT for SvelteKit
- Footer: shows version+short commit when not 'dev' (text-zinc-800, subtle)
- docker-compose: args blocks for scraper and ui build sections pass $GIT_TAG/$GIT_COMMIT
/api/comments/[id] and /api/comments/[slug] were ambiguous dynamic segments at
the same path level, causing a build error. Moved the vote handler to the
singular /api/comment/ prefix and updated all callers (web + iOS).
- Batch-resolve avatar presign URLs server-side in GET /api/comments/[slug];
returns avatarUrls map alongside comments and myVotes
- CommentsSection.svelte: show avatar image or initials fallback (24px top-level,
20px replies) next to each comment/reply username
- iOS CommentsResponse gains avatarUrls field; CommentsViewModel stores and
populates it on load; CommentRow renders AsyncImage with initials fallback
- Also includes: comment replies (1-level nesting), delete, sort (Top/New),
parent_id schema migration, and AvatarCropModal cropperjs fix
Production Docker image has no node_modules at runtime; marked was being
externalized by adapter-node (it is in dependencies), causing ERR_MODULE_NOT_FOUND
when +page.server.ts and +server.ts imported it. Adding it to ssr.noExternal
forces Vite to inline it into the server bundle.
marked({ async: true }) triggers a dynamic internal require inside marked
that vite/rollup treats as external, causing ERR_MODULE_NOT_FOUND at runtime
in the adapter-node Docker image which ships no node_modules.
Switching to the synchronous marked() call makes rollup inline the full
library into the server chunk.
Static import of 'marked' was included in the SSR bundle causing
ERR_MODULE_NOT_FOUND on first server render. The import is only
ever used inside onMount (client-only fallback path), so replace
with a dynamic import() at the call site.
- Go scraper: add PresignAvatarUploadURL/PresignAvatarURL/DeleteAvatar to
Store interface, implement on HybridStore+MinioClient, register
GET /api/presign/avatar-upload/{userId} and /api/presign/avatar/{userId}
- SvelteKit: replace direct AWS S3 SDK in minio.ts with presign calls to
the Go scraper; rewrite avatar +server.ts (POST=presign, PATCH=record key)
- iOS: rewrite uploadAvatar() as 3-step presigned PUT flow; refactor
chip components into shared ChipButton in CommonViews.swift
- Replace AudioGenerateResponse with AudioTriggerResponse to handle both 200 (cached) and 202 (async job) responses
- Add pollAudioStatus() in APIClient that polls /api/audio/status every 2s until done or failed
- Update generateAudio() and prefetchNext() in AudioPlayerService to poll instead of expecting a synchronous URL response
- Bump chapter list font sizes: number xs→sm, title sm→base, date/badge xs→sm, row padding py-2→py-2.5
New page mirrors the scrape tasks pattern: loads audio_jobs from
PocketBase via a new listAudioJobs() helper, shows a filterable table
(slug, chapter, voice, status, started, duration, error), and live-polls
every 3s while any job is pending or generating. Also fixes the
/admin/audio nav active-state check (was startsWith, now exact match)
to prevent it from matching /admin/audio-jobs.
Replace blocking POST /api/audio with a non-blocking 202 flow: the Go
handler immediately enqueues a job in a new `audio_jobs` PocketBase
collection and returns {job_id, status}. A background goroutine runs
the actual Kokoro TTS work and updates job status (pending → generating
→ done/failed). A new GET /api/audio/status/{slug}/{n} endpoint lets
clients poll progress. The SvelteKit proxy and AudioPlayer.svelte are
updated to POST, then poll the status route every 2s until done.
- Add user_sessions PocketBase collection (user_id, session_id, user_agent, ip, created/last_seen)
- Extend auth token format to include a per-login authSessionId (4th segment)
- Hook validates authSessionId against DB on each request; revoked sessions are cleared immediately
- Login/register create a session record capturing user-agent and IP
- Profile page shows all active sessions with current session highlighted; per-session End/Sign out buttons
- GET /api/sessions and DELETE /api/sessions/[id] endpoints for client-side revocation
- Backward compatible: legacy 3-segment tokens pass through without DB check
- Store cover and chapters array in audioStore (populated by AudioPlayer on startPlayback)
- Page server returns full chapters list on normal path; empty array on preview
- Mini-bar: replace arrow link with book cover thumbnail (fallback book icon)
- Mini-bar track info area is now a button that toggles a chapter list drawer
- Chapter drawer slides up above the mini-bar, lists all chapters with current one highlighted
Adds GET /api/search Go endpoint that queries PocketBase books by title/author
substring and fetches novelfire.net /search results via parseBrowsePage(),
merging results with local-first de-duplication. The browse page gains a search
input that navigates to ?q=; results show local vs remote counts and hide
pagination/filter controls while in search mode.
Adds FromChapter/ToChapter fields to orchestrator.Config and skips out-of-range
chapters in processBook. Exposes POST /scrape/book/range Go endpoint and a
matching UI proxy at /api/scrape/range. The book detail page now shows admins
a range input (from/to chapter) and a per-chapter 'scrape from here up' button.
Adds GET /api/book-preview/{slug} and GET /api/chapter-text-preview/{slug}/{n}
Go endpoints that scrape live from novelfire.net without persisting to
PocketBase or MinIO. The UI book page falls back to the preview endpoint when
a book is not found in PocketBase, showing a 'not in library' badge and the
scraped chapter list. Chapter pages handle ?preview=1 to fetch and render
chapter text live, skipping MinIO and suppressing the audio player.
- Remove SingleFile CLI and Node.js from Dockerfile; runtime base reverted to alpine:3.21
- Replace triggerBrowseSnapshot with triggerDirectScrape: plain Go HTTP GET to novelfire.net
(page is server-rendered, no browser needed)
- Fix Accept-Encoding bug: stop setting header manually so Go transport auto-decompresses gzip
- Add in-memory browse cache fallback (browseMemCache) for when MinIO and upstream both fail
- Add warmBrowseCache() startup goroutine
- Call triggerDirectScrape on MinIO cache hits too, so PocketBase ranking records are
repopulated after a schema reset or fresh deploy without waiting for a cache miss
- Fix grid view cards: wrap in <a> instead of <div> so clicking navigates to book detail
- Remove SINGLEFILE_PATH and BROWSERLESS_URL env vars from Dockerfile
The previous implementation sent a single request with perPage=500 and
silently dropped any records beyond that. Books with 900+ chapters were
truncated to 500 in the chapter list and reader prev/next navigation.
Now loops through all pages until totalItems is exhausted.
- Speed is no longer part of Kokoro generation, in-memory cache keys, or
MinIO object keys — audio is always generated at 1.0 and playback speed
is applied client-side via audioEl.playbackRate
- presignAudio() now calls rewriteHost() so chapter audio URLs use the
public MinIO endpoint (same as presignVoiceSample already did)
- docker-compose.yml: rename MINIO_PUBLIC_ENDPOINT → PUBLIC_MINIO_PUBLIC_URL
for the ui service so SvelteKit's $env/dynamic/public picks it up
- Fix speed/voice bug: AudioPlayer no longer accepts speed/voice as props;
startPlayback() reads audioStore.voice/speed directly instead of overwriting them
- Add GET /api/voices endpoint (Go) proxying Kokoro, cached in-memory
- Add POST /api/audio/voice-samples endpoint (Go) to pre-generate sample clips
for all voices and store them in MinIO under _voice-samples/{voice}.mp3
- Add GET /api/presign/voice-sample/{voice} endpoint (Go)
- Add SvelteKit proxy routes: /api/voices, /api/presign/voice-sample, /api/audio/voice-samples
- Add presignVoiceSample() helper in minio.ts with proper host rewrite
- Pass book.cover through +page.server.ts -> +page.svelte -> AudioPlayer
- Set navigator.mediaSession.metadata on playback start so cover art,
book title, and chapter title appear on phone lock screen / notification
Admins see a Rescrape button next to the chapter list header.
Clicking it POSTs to /api/scrape with the book's source_url,
then shows an inline status banner (queued / busy / error).
isAdmin is exposed from the server load; no new routes needed.
The boolean flag was set by onended before goto() resolved, causing the
still-mounted outgoing chapter's AudioPlayer $effect to fire and call
startPlayback() for the wrong (old) chapter — restarting it from scratch.
Replace with autoStartChapter (number | null): the AudioPlayer only acts
when its own chapter prop === autoStartChapter, so the outgoing component
never matches and the incoming one fires exactly once on mount.
The stale-prefetch reset effect compared prefetchedFor against nextChapter
only, so when landing on chapter N+1 (prefetchedFor=N+1, nextChapter=N+2)
it would destroy the pre-fetched URL before startPlayback() could use it,
breaking every auto-next transition after the first.
Fix: also allow prefetchedFor === chapter (current page) so the URL
survives long enough to be consumed, then only wipe truly foreign values.
When autoNext is on, kick off prefetchNext() as soon as the current
chapter begins playing (all three success paths in startPlayback).
This gives the full chapter duration as lead time for Kokoro TTS
instead of only the last 10%, making auto-next transitions seamless.
The existing 90%-mark $effect is kept as a fallback for cases where
autoNext is toggled on mid-playback after startPlayback has returned.
- Add NextStatus type and prefetch state (nextStatus, nextAudioUrl, nextProgress, nextChapterPrefetched) to AudioStore
- AudioPlayer triggers prefetchNext() when currentTime/duration >= 0.9, autoNext is on, and a next chapter exists
- startPlayback() uses the pre-fetched URL if available (skipping presign round-trip and generation wait)
- Auto-next button in layout shows a pulsing dot while prefetching and a green dot when ready
- AudioPlayer shows inline prefetch progress and ready state below the controls
- Fix indentation regression in layout onended handler
- Clear autoStartPending if goto() fails to avoid spurious auto-starts on future navigations
- Clear audioStore.nextChapter on AudioPlayer unmount so a stale chapter can't trigger navigation after leaving a chapter page
- Remove redundant nextChapter write in startPlayback() — the already keeps it in sync