PageSourceSearch

https://selebox.com/js/books.js?releaseQA=20261006-2&singleViewer=…&socialAccount=20261007

js selebox.com collected 2026-10-07 23:57:23 UTC 215,855 bytes, 4,126 lines download raw bytes

1import {hydrateCatalogBooks,livePartCount,readAllLiveParts,partLiveDate} from './books-catalog-policy.js?polish=20261006-1&otaStep5=2026-10-06-1&v=20261006-step5';
2import {isAiGeneratedBook,AI_GENERATED_COPY} from './book-publishing-policy.js?polish=20261006-1&otaStep5=2026-10-06-1&v=20261006-step5';
3import { bindCommentMentions, commentMentionBody, renderCommentMentions } from './comment-mentions.js?polish=20261006-1&otaStep9=2026-10-06-1&v=20261006-step9';
4import {renderDisplayBadge} from './display-badge.js?otaStep3=2026-10-06-1&v=2026-10-06-1';
5import { createLibraryDataSource } from './books-library-data.js?polish=20261006-1&otaStep5=2026-10-06-1&otaStep3=2026-10-06-1&v=2026-09-19-library-3&badges=2026-10-06-1';
6import { createLibraryListsDataSource } from './books-library-lists-data.js?polish=20261006-1&otaStep5=2026-10-06-1&otaStep3=2026-10-06-1&v=2026-09-19-library-3&badges=2026-10-06-1';
7import { createRankingDataSource } from './books-ranking-data.js?polish=20261006-1&otaStep5=2026-10-06-1&otaStep3=2026-10-06-1&v=2026-09-19-library-3&badges=2026-10-06-1';
8import { shareRankedBook } from './books-ranking-actions.js?v=2026-09-19-library-3';
9import { createReadingListDataSource } from './books-reading-list-data.js?polish=20261006-1&otaStep5=2026-10-06-1&otaStep3=2026-10-06-1&v=2026-09-19-wired-3&badges=2026-10-06-1';
10import { createDiscoverDataSource } from './books-discover-data.js?polish=20261006-1&otaStep5=2026-10-06-1&otaStep3=2026-10-06-1&v=2026-09-27-audit-1&badges=2026-10-06-1';
11import { mountDiscoverBattle } from './books-discover-battle.js?v=2026-09-19-wired-3';
12import { createForYouDataSource } from './books-foryou-data.js?polish=20261006-1&otaStep5=2026-10-06-1&otaStep3=2026-10-06-1&v=2026-09-19-performance-1&badges=2026-10-06-1';
13// ════════════════════════════════════════════════════════════════════════
14// Selebox Books listing + discovery — extracted from js/app.js as Stage 8A
15// of the refactor roadmap. This module owns the READ side of the books
16// surface — everything that paints lists or fetches collections of books.
17// The detail page + chapter reader live in Stage 8B (deferred so the
18// IIFE / DOM-wiring there can get its own dedicated init helper without
19// gating this commit).
20//
21// In this module:
22//   • showBook + loadBooksTab — page entry + tab dispatcher
23//   • For You tab: _loadForYouTab + 4 rail-specific fetchers
24//     (_fetchWeeklyFeaturedWithFallback, _fetchRecommendedForUser,
25//      _fetchHiddenGems, _fetchQuickReads) + the _FORYOU_SECTIONS table
26//   • Discover tab: _loadDiscoverTab + _loadDiscoverGenreRow
27//   • Ranking tab: _loadRankingTab + _renderRankGenreChips +
28//     _loadRankingForGenre + _renderRankCard
29//   • Reading List tab: _loadCollectionTab + _loadReadingListTab
30//   • See All sub-view: _openBookSeeAll + _loadMoreSeeAllBooks +
31//     _setupSeeAllInfiniteScroll + _SEE_ALL_MAP
32//   • Search: runBookSearch + searchBooks + renderWriterChannelCard
33//   • Filter / normalisation: applyBookFilter, _normalizeBookRow/Rows
34//   • Data fetchers: fetchSupabaseBooks, fetchBooksServerSearch
35//   • Card renderers: renderBookCard, _renderBookCardV2, _renderBookSection
36//   • Recommendation rail: getUserBookTaste, renderBookChips,
37//     loadBookRecommendations, renderBookRecsRail
38//   • Genre helpers: prettyGenre + PRETTY_GENRE table
39//
40// NOT moved here (deferred to Stage 8B):
41//   • openBookDetail + renderBookDetail (book detail page)
42//   • openChapterReader + reader helpers (chapter reader page)
43//   • toggleBookLike + toggleBookBookmark + loadBookActionState (book
44//     detail action buttons — coupled to the detail page lifecycle)
45//   • showBookmarks + loadBookmarks + loadBookBookmarks (bookmarks
46//     dispatcher — stays paired with the detail-page toggle)
47//   • Reader watermark, anti-copy IIFE, font controls, prev/next wiring
48//
49// NOT moved at all (stays in app.js by design):
50//   • _previewBookBulkUnlock + openBulkBookUnlockDialog (bulk-unlock
51//     dialog is shared with the video paywall — same modal markup)
52//   • _replaceBookCoverFromHome (home page mosaic territory)
53//   • _getWebBookPool (home feed concern, called by feed.js too)
54//   • Studio book editor: openNewBookModal / openAuthorBookEditor /
55//     loadBookEditor / saveBookMetadata / saveChapter etc. — those
56//     belong in a future Studio extract
57//
58// CAREFUL: pure code movement. Inward references rewritten to _cfg.X via
59// the Stage 8A jscodeshift codemod (scripts/extract-stage8a.js). Module-
60// private state lives at the bottom of this file. Page-level wiring
61// (tab click delegation, See-All click delegation, See-All back button)
62// stays in app.js because it has to run after the bookPage DOM exists —
63// app.js loads first and already owns that timing concern.
64// ════════════════════════════════════════════════════════════════════════
65
66import { createChapterCommentWriter } from './chapter-comment-writer.js?v=2026-09-28-1';
67import { supabase, toast, escHTML, initials, resolveProfileForAuthUser } from './supabase.js?otaStep7=2026-10-06-1&v=20261006-step7';
68import {renderPublishedChapter} from './books-epistolary-reader.js?v=social-account-v2';
69import { refreshUnlocks } from './wallet.js?releaseQA=20261006-2&singleViewer=20261006-1&otaStep7=2026-10-06-1&otaStep3=2026-10-06-1';
70import { resolveChapterTarget, booksListingUrl, createBooksMobileController, isBooksV2PreviewEnabled }
70 from './books-mobile.js?visual=20261006-2&polish=20261006-1&otaStep5=2026-10-06-1&otaStep3=2026-10-06-1&v=2026-09-19-production-ready-1&search=2026-09-20-1';
71
72// Books Fix Pack 1 (2026-06-15): vendored DOMPurify for reader-side chapter
73// HTML sanitization. LOCAL vendor file only (no CDN). Books-owned — does NOT
74// import or reuse js/writer/* or the mobile sanitizer; Books and Writer's
75// Lounge stay separated.
76import DOMPurify from './vendor/purify.es.mjs';
77import { CANONICAL_NOVEL_IMAGE_ATTRS } from './author-chapter-canonical-image.js?aci=2026-08-16-author-canonical-image-fidelity-1';
78import { enforceCanonicalFigureTrust } from './books-canonical-image-trust.js?aci=2026-08-16-author-canonical-image-fidelity-1';
79
80// ─── Config-injection dependency surface ─────────────────────────────────
81// These are app.js-owned helpers that books code calls into. The codemod
82// rewrites bare `helperName(...)` calls inside extracted functions to
83// `_cfg.helperName(...)`. Defaults are no-ops so the module loads cleanly
84// even before initBooks() has been called from app.js.
85let _cfg = {
86  // Identity / session
87  getCurrentUser:        () => null,
88
89  // Navigation / page switching
90  hideAllMainPages:      () => {},
91  stopVideoPlayer:       () => {},
92  openProfile:           () => {},
93  showAuth:              () => {},
94
95  // (openBookDetail used to be bridged here — removed in Stage 8B Codex
96  // review. The function lives in this module now; book cards / ranking
97  // rows / writer-channel cards call it directly as a sibling instead of
98  // routing through _cfg, which used to silently no-op against the
99  // default and break every "tap a book" surface.)
100
101  // Formatters (shared with feed/videos)
102  formatCompact:         (n) => String(n || 0),
103
104  // Search-input sanitisers from app.js
105  sanitizeSearchQuery:   (s) => s || '',
106  escapeIlike:           (s) => s || '',
107  normalizeForSearch:    (s) => (s || '').toString().toLowerCase().trim(),
108
109  // CDN URL helpers (live in app.js as top-level const arrows). Bridged
110  // because _renderBookCardV2 composes the cover URL through them.
111  _cleanCdnUrl:          (u) => u,
112  _supabaseRatioCrop:    (u, _opts) => u,
113
114  // ─── Stage 8B bridges (book detail page + chapter reader) ────────────
115  // Identity getter for currentProfile — read by the Editor's-Pick btn
116  // visibility check in renderBookDetail.
117  getCurrentProfile:     () => null,
118
119  // Wallet config — read as an object via `.field` from the wallet.js
120  // app_config snapshot (getWalletConfig()). Stage 13A moved this out
121  // of app.js's top-level `_walletConfigDefaults` into the wallet
122  // owner module. The codemod rewrites every bare `_walletConfigDefaults.X`
123  // read to `_cfg.getWalletConfig().X`.
124  getWalletConfig:       () => ({}),
125
126  // Unlock / paywall (shared with feed + videos — app.js owns).
127  openUnlockDialog:      () => {},
128  openBulkBookUnlockDialog: () => {},
129  isUnlocked:            () => false,
130  resolveUnlockCost:     () => 0,
131
132  // Engagement counters + anti-fraud telemetry. (flushReadClose lives
133  // in this module since Stage 8B — it pairs with the four open-state
134  // vars below. No bridge needed.)
135  tickGoalUnique:        () => {},
136  logRead:               () => {},
137};
138
139export function initBooks(config) {
140  if (config) _cfg = { ..._cfg, ...config };
141}
142
143// Eager DOM refs — these elements exist in index.html. Books.js is loaded
144// as an ES module via app.js's import graph, which executes after the HTML
145// parser has built the body, so getElementById here is safe. App.js holds
146// its own copies of `bookDetailPage` + `chapterReaderPage` because
147// hideAllMainPages reads them directly — duplicating the lookup here keeps
148// each module self-contained without a cross-import dependency on those
149// const exports.
150const bookPage         = document.getElementById('bookPage');
151const bookDetailPage   = document.getElementById('bookDetailPage');
152const chapterReaderPage = document.getElementById('chapterReaderPage');
153const booksMobileRoot   = document.getElementById('booksMobileV2');
154let _booksMobileController = null;
155let _booksForYouSource = null;
156let _booksDiscoverSource = null;
157let _booksRankingSource = null;
158function _getBooksRankingSource() {
159  return _booksRankingSource ||= createRankingDataSource({supabase,getUserId:() => _cfg.getCurrentUser()?.id || null});
160}
161let _booksLibrarySource = null;
162let _booksLibraryListsSource = null;
163async function _getBooksLibraryProfileId() {
164  const user = _cfg.getCurrentUser();
165  if (!user) return null;
166  const profile = _cfg.getCurrentProfile?.();
167  if (profile?.id) return profile.id;
168  const {data,error} = await resolveProfileForAuthUser(user.id, 'id');
169  if (error) throw error;
170  return data?.id || null;
171}
172function _getBooksLibrarySource() {
173  return _booksLibrarySource ||= createLibraryDataSource({supabase,getUserId:()=>_cfg.getCurrentUser()?.id || null,getProfileId:_getBooksLibraryProfileId});
174}
175function _getBooksLibraryListsSource() {
176  return _booksLibraryListsSource ||= createLibraryListsDataSource({supabase,getUserId:()=>_cfg.getCurrentUser()?.id || null,getProfileId:_getBooksLibraryProfileId});
177}
178let _booksReadingListSource = null;
179function _getBooksReadingListSource() {
180  return _booksReadingListSource ||= createReadingListDataSource({
181    supabase, getUserId: () => _cfg.getCurrentUser()?.id || null,
182    getProfileId: async () => {
183      const user = _cfg.getCurrentUser();
184      if (!user) return null;
185      if (_cfg.getCurrentProfile()?.id) return _cfg.getCurrentProfile().id;
186      const { data, error } = await resolveProfileForAuthUser(user.id, 'id');
187      if (error) throw error;
188      return data?.id || null;
189    },
190  });
191}
192function _getBooksDiscoverSource() {
193  return _booksDiscoverSource ||= createDiscoverDataSource({
194    supabase,
195    getUserId: () => _cfg.getCurrentUser()?.id || null,
196    getProfileId: async () => {
197      const user = _cfg.getCurrentUser();
198      if (!user) return null;
199      if (_cfg.getCurrentProfile()?.id) return _cfg.getCurrentProfile().id;
200      const { data, error } = await resolveProfileForAuthUser(user.id, 'id');
201      if (error) throw error;
202      return data?.id || null;
203    },
204  });
205}
206function _getBooksForYouSource() {
207  if (!_booksForYouSource) _booksForYouSource = createForYouDataSource({
208    supabase,
209    getUserId: () => _cfg.getCurrentUser()?.id || null,
210    getProfileId: async () => {
211      const user = _cfg.getCurrentUser();
212      if (!user) return null;
213      if (_cfg.getCurrentProfile()?.id) return _cfg.getCurrentProfile().id;
214      const { data, error } = await resolveProfileForAuthUser(user.id, 'id');
215      if (error) throw error;
216      return data?.id || null;
217    },
218    fetchBooks: (sort, limit, opts = {}) => fetchSupabaseBooks(0, limit, sort, { ...opts, throwOnError: true }),
219    fetchFeatured: (limit) => _fetchWeeklyFeaturedWithFallback(limit),
220    fetchContinueReading: _fetchBooksMobileContinue,
221  });
222  return _booksForYouSource;
223}
224
225async function _fetchBooksMobileLibrary() {
226  const userId = _cfg.getCurrentUser()?.id;
227  if (!userId) return [];
228  const { data, error } = await supabase.from('book_bookmarks').select(`book_id, created_at, books!book_bookmarks_book_id_fkey(${BOOK_CARD_SELECT})`).eq('user_id', userId).order('created_at', { ascending: false }).limit(80);
229  if (error) throw error;
230  return _normalizeBookRows(await hydrateCatalogBooks(supabase,filterReaderEligibleBooks((data || []).map(row => row.books))));
231}
232
233async function _fetchBooksMobileReadingLists() {
234  const authUser = _cfg.getCurrentUser();
235  if (!authUser) return [];
236  let profile = _cfg.getCurrentProfile?.();
237  if (!profile?.id) {
238    const resolved = await resolveProfileForAuthUser(authUser.id, 'id');
239    if (resolved?.error) throw resolved.error;
240    profile = resolved?.data;
241  }
242  if (!profile?.id) return [];
243  const { data, error } = await supabase.from('reading_lists').select(`id, title, created_at, reading_list_books(id, added_at, book:books(${BOOK_CARD_SELECT}))`).eq('owner_id', profile.id).order('created_at', { ascending: false });
244  if (error) throw error;
245  return (data || []).map(list => ({ ...list, reading_list_books: (list.reading_list_books || []).filter(entry => isReaderEligibleBook(entry.book)) }));
246}
247
248async function _fetchBooksMobileContinue() {
249  const userId = _cfg.getCurrentUser()?.id;
250  if (!userId) return [];
251  const { data, error } = await supabase.from('book_reads').select(`book_id, last_chapter_number, last_scroll_pct, last_read_at, books!book_reads_book_id_fkey(${BOOK_CARD_SELECT},cover_variants_ready)`).eq('user_id', userId).order('last_read_at', { ascending: false }).limit(12);
252  if (error) throw error;
253  return (data || []).filter(row => isReaderEligibleBook(row.books)).map(row => ({ ..._normalizeBookRow(row.books), _lastChapterNumber: row.last_chapter_number, _lastScrollPct: row.last_scroll_pct, _lastReadAt: row.last_read_at }));
254}
255
256async function _toggleBooksMobileSave(bookId) {
257  const userId = _cfg.getCurrentUser()?.id;
258  if (!userId) { toast('Sign in to save books', 'error'); return false; }
259  const { data, error } = await supabase.from('book_bookmarks').select('book_id').eq('user_id', userId).eq('book_id', bookId).maybeSingle();
260  if (error) throw error;
261  const result = data ? await supabase.from('book_bookmarks').delete().eq('user_id', userId).eq('book_id', bookId) : await supabase.from('book_bookmarks').insert({ user_id: userId, book_id: bookId });
262  if (result.error && !/duplicate|unique/i.test(result.error.message || '')) throw result.error;
263  return !data;
264}
265
266async function _fetchBooksMobileSavedIds(bookIds) {
267  const userId = _cfg.getCurrentUser()?.id;
268  if (!userId || !bookIds?.length) return new Set();
269  const { data, error } = await supabase.from('book_bookmarks').select('book_id').eq('user_id', userId).in('book_id', bookIds);
270  if (error) throw error;
271  return new Set((data || []).map(row => row.book_id));
272}
273
274function _getBooksMobileController() {
275  if (!booksMobileRoot || !isBooksV2PreviewEnabled()) return null;
276  if (!_booksMobileController) _booksMobileController = createBooksMobileController({ root: booksMobileRoot, adapter: {
277    auth:supabase.auth,
278    getAccountId: () => _cfg.getCurrentUser()?.id || null,
279    loadRanking: (options) => _getBooksRankingSource().load(options),
280    shareBook: shareRankedBook,
281    loadLibrary: options => _getBooksLibrarySource().load(options),
282    loadOwnReadingLists: () => _getBooksLibrarySource().loadOwnLists(),
283    removeLibraryBooks: ids => _getBooksLibrarySource().remove(ids),
284    addLibraryBooksToList: (listId,ids) => _getBooksLibrarySource().addToList(listId,ids),
285    canDownload: false,
286    loadLibraryLists: options => _getBooksLibraryListsSource().load(options),
287    loadLibraryListEntries: (listId,options) => _getBooksLibraryListsSource().loadEntries(listId,options),
288    createLibraryList: name => _getBooksLibraryListsSource().create(name),
289    renameLibraryList: (id,name) => _getBooksLibraryListsSource().rename(id,name),
290    deleteLibraryList: id => _getBooksLibraryListsSource().removeList(id),
291    removeLibraryListEntry: (listId,entryId) => _getBooksLibraryListsSource().removeEntry(listId,entryId),
292    setLibraryListPublic: (id,isPublic) => _getBooksLibraryListsSource().setPublic(id,isPublic),
293    toast: (message) => toast(message),
294    loadDiscover: (options) => _getBooksDiscoverSource().load(options),
295    searchDiscover: (query, options) => _getBooksDiscoverSource().search(query, options),
296    loadDiscoverCategory: (label, options) => _getBooksDiscoverSource().loadCategory(label, options),
297    mountBattle: (root, navigation) => mountDiscoverBattle(root, { initialBattleId: navigation?.battleId || null, supabase, getAccountId: () => _cfg.getCurrentUser()?.id || null, openBook: openBookDetail, signIn: () => _cfg.showAuth() }),
298    signIn: () => _cfg.showAuth(),
299    loadForYouData: options => _getBooksForYouSource().load(options),
300    loadForYouSection: (key) => _getBooksForYouSource().loadSection(key),
301    loadForYouSectionPage: (key, options) => _getBooksForYouSource().loadSectionPage(key, options),
302    refreshForYouData: () => _getBooksForYouSource().refresh(),
303    loadReadingList: (id, options) => _getBooksReadingListSource().load(id, options),
304    openAuthor: (id) => _cfg.openProfile(id),
305    fetchBooks: (sort, limit, opts = {}) => fetchSupabaseBooks(0, limit, sort, { ...opts, throwOnError: true }),
306    fetchFeatured: (limit) => _fetchWeeklyFeaturedWithFallback(limit),
307    searchBooks: async (query, options) => (await fetchBooksServerSearch(query, { ...options, throwOnError: true })).books,
308    fetchContinueReading: _fetchBooksMobileContinue,
309    fetchLibrary: _fetchBooksMobileLibrary,
310    fetchReadingLists: _fetchBooksMobileReadingLists,
311    fetchSavedIds: _fetchBooksMobileSavedIds,
312    openBook: (id, opts) => openBookDetail(id, opts),
313    toggleSave: _toggleBooksMobileSave,
314  } });
315  return _booksMobileController;
316}
317
318// ════════════════════════════════════════════════════════════════════════
319// Extracted state + functions are appended below by the Stage 8A script.
320// ════════════════════════════════════════════════════════════════════════
321
322
323// ─── Module state ─────────────────────────────────────────────────
324// ════════════════════════════════════════
325// BOOK / READER
326// ════════════════════════════════════════
327let allBooksCache = [];     // filtered + sorted view (what's rendered)
328let allBooksRaw   = [];     // unfiltered raw books fetched from server (for fast tab switching)
329let bookGenreFilter = '';
330let bookSortBy = 'trending';
331let activeBookSearchQuery = '';
332// ── Books page (mirrors mobile-app shape: tabbed sections of curated rows) ──
333//
334// Tabs:
335//   foryou      → Weekly Featured (editor's picks) / Fresh Reads / Completed & Excellent
336//   discover    → Genre-grouped rows of top books
337//   ranking     → Most Loved / Most Read / Trending This Week
338//   readinglist → User's bookmarks
339//
340// (Library was dropped from web — the sidebar's existing Bookmarks page
341// already plays that role; mobile app's "Library" maps to web's Bookmarks.)
342//
343// Each section has a See All link that opens an in-page list view with
344// infinite scroll, sorted/filtered the same way that section was populated.
345// ────────────────────────────────────────────────────────────────────────────
346let _activeBookTab = 'foryou';
347const _bookTabLoaded = { foryou: false, discover: false, ranking: false, readinglist: false };
348let _booksOffset = 0;
349let _rankingActiveGenre = '';
350let _rankingSeq = 0;
351// ── See All sub-view ───────────────────────────────────────────────────────
352let _seeAllSeq = 0;
353let _seeAllOffset = 0;
354let _seeAllSort = '';
355let _seeAllGenre = null;
356// Optional custom predicate for See-All — set when the section had a `filter`
357// in _SEE_ALL_MAP (Hidden Gems, Quick Reads). Threaded through to fetchSupabaseBooks.
358let _seeAllFilter = null;
359let _seeAllHasMore = false;
360let _seeAllLoading = false;
361let _seeAllObserver = null;
362// Cache for user's book reading taste (reads + likes) — used by both
363// renderBookChips() and loadBookRecommendations() in the same page load.
364// 60-second TTL so stale-but-fresh-enough data doesn't trigger 2 round-trips.
365let _userBookTasteCache = null;
366let _userBookTasteAt = 0;
367let _bookRecsCache = null;
368let _bookRecsTimestamp = 0;
369// (The old fire-and-render `_loadBookSection` was removed when For You and
370// Ranking moved to the parallel-fetch + claim-by-priority pattern below.
371// `_renderBookSection` is the new render-only helper.)
372
373// ── For You tab — 10 Wattpad-inspired rails ────────────────────────────────
374//
375// Strategy: fetch all rails in parallel (each overfetches 2× for dedup
376// headroom), then walk them in priority order and CLAIM books — once a
377// book lands in an earlier rail, later rails skip it. This guarantees
378// every book appears at most once across the whole For You tab, while
379// keeping the parallel speed (no sequential wait between waves).
380//
381// Order is the priority order: curated → personal → fresh activity →
382// discovery → format → all-time → completed. Edit this array to reorder.
383const _SECTION_ROW_SIZE = 7;
384const _FORYOU_SECTIONS = [
385  { key: 'weeklyFeatured',     fetch: (n) => _fetchWeeklyFeaturedWithFallback(n),                       empty: 'No featured books yet' },
386  { key: 'recommended',        fetch: (n) => _fetchRecommendedForUser(n),                               empty: 'Read a few books to get personalised picks' },
387  { key: 'trending',           fetch: (n) => fetchSupabaseBooks(0, n, 'trending'),                      empty: 'Nothing trending yet' },
388  { key: 'freshReads',         fetch: (n) => fetchSupabaseBooks(0, n, 'recent'),                        empty: 'No fresh reads' },
389  { key: 'justUpdated',        fetch: (n) => fetchSupabaseBooks(0, n, 'just-updated'),                  empty: 'No recent updates' },
390  { key: 'hiddenGems',         fetch: (n) => _fetchHiddenGems(n),                                       empty: 'No hidden gems found' },
391  { key: 'quickReads',         fetch: (n) => _fetchQuickReads(n),                                       empty: 'No quick reads yet' },
392  { key: 'mostLoved',          fetch: (n) => fetchSupabaseBooks(0, n, 'most-liked'),                    empty: 'Nothing here yet' },
393  { key: 'mostRead',           fetch: (n) => fetchSupabaseBooks(0, n, 'most-read'),                     empty: 'Nothing here yet' },
394  { key: 'completedExcellent', fetch: (n) => fetchSupabaseBooks(0, n, 'completed'),                     empty: 'No completed books yet' },
395];
396// ── Ranking tab — Top-100 leaderboard with genre filter (App-aligned) ──────
397// One ranked vertical list per genre, numbered ribbons on each row. The
398// chip rail at the top filters by genre; "All" shows the platform-wide Top 100.
399// Sort is by likes_count (the canonical "Most Loved" axis) — the same axis
400// the mobile app uses for its single ranking score.
401const _RANKING_GENRES = [
402  { slug: '',                  label: 'All' },
403  { slug: 'dark-romance',      label: 'Dark Romance' },
404  { slug: 'mafia-boss',        label: 'Mafia Boss' },
405  { slug: 'billionaire',       label: 'Billionaire' },
406  { slug: 'enemies-to-lovers', label: 'Enemies to Lovers' },
407  { slug: 'forbidden-love',    label: 'Forbidden Love' },
408  { slug: 'hot-romance',       label: 'Hot Romance' },
409  { slug: 'sci-fi',            label: 'Sci-fi' },
410  { slug: 'teen-fiction',      label: 'Teen Fiction' },
411  { slug: 'general-fiction',   label: 'General Fiction' },
412];
413// ── Discover tab — genre-grouped rows ──────────────────────────────────────
414const _DISCOVER_GENRES = [
415  'hot-romance', 'dark-romance', 'mafia-boss', 'enemies-to-lovers',
416  'forbidden-love', 'sci-fi', 'teen-fiction', 'general-fiction',
417];
418const _SEE_ALL_MAP = {
419  weeklyFeatured:     { sort: 'editors-pick',  title: 'Weekly Featured' },
420  recommended:        { sort: 'most-liked',    title: 'Recommended for You' },
421  trending:           { sort: 'trending',      title: 'Trending This Week' },
422  freshReads:         { sort: 'recent',        title: 'Fresh Reads' },
423  justUpdated:        { sort: 'just-updated',  title: 'Just Updated' },
424  hiddenGems:         { sort: 'most-liked',    title: 'Hidden Gems',
425                        filter: q => q.gte('likes_count', 3).lt('views_count', 500) },
426  quickReads:         { sort: 'most-liked',    title: 'Quick Reads',
427                        filter: q => q.gte('chapters_count', 1).lte('chapters_count', 5) },
428  mostLoved:          { sort: 'most-liked',    title: 'Most Loved' },
429  mostRead:           { sort: 'most-read',     title: 'Most Read' },
430  completedExcellent: { sort: 'completed',     title: 'Completed & Excellent Works' },
431};
432// ── Pagination state for the See All sub-view (back-compat shims kept for
433// any older code paths that still reference these names) ──
434const BOOKS_PAGE_SIZE = 80;
435// ── Book row helpers ──────────────────────────────────────────────────────
436// One source of truth for the columns the UI needs from the books table.
437// Used by every fetcher so adding/removing a column is a one-line change.
438const BOOK_LIST_SELECT = `
439  id, title, description, cover_url, genre, tags, status, content_rating,
440  is_public, is_hidden,
441  views_count, likes_count, ratings_avg, ratings_count, chapters_count, word_count,
442  views_last_7d, likes_last_7d, trending_score,
443  is_editors_pick, editors_pick_at, editors_pick_note,
444  lock_from_chapter, published_at, created_at, author_id,
445  profiles!books_author_id_fkey ( id, username, avatar_url, is_banned, role, roles, badge_role )
446`;
447// Slimmer projection — used by the home-feed dropdown and other places where
448// only cover/title/author are needed.
449const BOOK_CARD_SELECT = `
450  id, title, cover_url, genre, status, content_rating, is_public, is_hidden,
451  views_count, likes_count,
452  ratings_avg, ratings_count, chapters_count, lock_from_chapter, published_at, created_at, author_id,
453  profiles!books_author_id_fkey ( id, username, avatar_url, is_banned, role, roles, badge_role )
454`;
455
456// ── Public-reader eligibility (single source of truth) ───────────────────
457// A book is visible/openable/resumable/recommendable to public web readers ONLY
458// when public, not hidden, in a reader-visible status, and not by a banned
459// author. Intentionally STRICTER than "has published chapters": draft/paused
460// books can have published chapters in the DB but must stay unavailable on web
461// unless product policy changes.
462const PUBLIC_READER_BOOK_STATUSES = Object.freeze(['ongoing', 'completed']);
463const PUBLIC_READER_BOOK_STATUS_SET = new Set(PUBLIC_READER_BOOK_STATUSES);
464
465function isReaderEligibleBook(book) {
466  if (!book) return false;
467  if (book.is_public !== true) return false;
468  if (book.is_hidden === true) return false;
469  const status = String(book.status || '').toLowerCase();
470  if (!PUBLIC_READER_BOOK_STATUS_SET.has(status)) return false;
471  if (book.profiles?.is_banned === true) return false;
472  return true;
473}
474
475function filterReaderEligibleBooks(rows) {
476  return (rows || []).filter(isReaderEligibleBook);
477}
478
479// Server-side sort matrix. Each entry returns the chained query so callers
480// can range() it. Keeping the per-sort logic centralised here means
481// every code path that lists books paginates the same axis as page 1.
482const _BOOK_SORT_PIPELINES = {
483  // Trending: precomputed by refresh_book_trending_stats() (indexed).
484  trending:      (q) => q.order('trending_score', { ascending: false, nullsFirst: false })
485                          .order('likes_count',  { ascending: false })
486                          .order('created_at',   { ascending: false }),
487  recent:        (q) => q.order('published_at', { ascending: false, nullsFirst: false })
488                          .order('created_at',  { ascending: false }),
489  'most-liked':  (q) => q.order('likes_count', { ascending: false })
490                          .order('created_at', { ascending: false }),
491  'most-read':   (q) => q.order('views_count', { ascending: false })
492                          .order('created_at', { ascending: false }),
493  // Completed: filter then sort (likes_count index covers exactly this case).
494  completed:     (q) => q.eq('status', 'completed').order('likes_count', { ascending: false }),
495  // Editor's Pick: filter then sort by editors_pick_at (indexed).
496  'editors-pick':(q) => q.eq('is_editors_pick', true).order('editors_pick_at', { ascending: false, nullsFirst: false }),
497  // Just Updated: surfaces ongoing series with recent chapter drops.
498  'just-updated':(q) => q.order('updated_at', { ascending: false, nullsFirst: false })
499                          .order('created_at', { ascending: false }),
500};
501// (setupBooksInfiniteScroll removed — the See-All sub-view uses
502// _setupSeeAllInfiniteScroll instead. Other tabs render fixed-size sections.)
503
504// ── Supabase books ──
505// ────────────────────────────────────────────────────────────────────────
506// Adaptive book chip rail — YouTube-style: tags from user's reads + platform popular
507// Re-renders on each books-page open so it adapts as reading habits evolve.
508// ────────────────────────────────────────────────────────────────────────
509const PRETTY_GENRE = {
510  'hot-romance':         'Hot Romance',
511  'dark-romance':        'Dark Romance',
512  'mafia-boss':          'Mafia Boss',
513  'enemies-to-lovers':   'Enemies to Lovers',
514  'forbidden-love':      'Forbidden Love',
515  'arranged-marriage':   'Arranged Marriage',
516  'contract-marriage':   'Contract Marriage',
517  'second-chance':       'Second Chance',
518  'boy-love-bl':         'Boy Love',
519  'girl-love-gl':        'Girl Love',
520  'sci-fi':              'Sci-fi',
521  'teen-fiction':        'Teen Fiction',
522  'general-fiction':     'General Fiction',
523  'slice-of-life':       'Slice of Life',
524  'one-shot-story':      'One Shot Story',
525  'valentines-special':  'Valentines Special',
526};
527const BOOK_RECS_TTL = 5 * 60 * 1000; // 5 min
528
529// ─── Extracted functions ──────────────────────────────────────────
530
531function showBook(force = false) {
532  _cfg.hideAllMainPages();
533  bookPage.style.display = 'block';
534  // Wider canvas: 7 covers per row needs the full viewport, not the 900px
535  // home-feed column. The body class is removed by hideAllMainPages on nav.
536  document.body.classList.add('on-books');
537  _cfg.stopVideoPlayer();
538  history.pushState(null, '', booksListingUrl());
539  const mobileController = _getBooksMobileController();
540  bookPage.classList.toggle('books-mobile-v2', !!mobileController);
541  if (mobileController) {
542    // Re-entering Books must read current progress, including reads made on mobile.
543    _booksForYouSource?.invalidate({onlySettled:!force});
544    if (force) { _booksDiscoverSource?.invalidate(); _booksRankingSource?.invalidate(); }
545    _booksLibrarySource?.invalidate();
546    _booksLibraryListsSource?.invalidate();
547    mobileController.show(force);
548    return;
549  }
550  // Hide See-All sub-view if it was open from a previous visit.
551  const seeAll = document.getElementById('bookSeeAllView');
552  if (seeAll) seeAll.style.display = 'none';
553  // Show the active tab's panel; hide the rest.
554  document.querySelectorAll('.book-tab-panel').forEach(p => {
555    const isActive = p.dataset.bookPanel === _activeBookTab;
556    p.style.display = isActive ? '' : 'none';
557    p.classList.toggle('active', isActive);
558  });
559  loadBooksTab(_activeBookTab, force);
560}
561
562function loadBooksTab(tab, force = false) {
563  _activeBookTab = tab;
564  if (!force && _bookTabLoaded[tab]) return;
565  _bookTabLoaded[tab] = true;
566  if (tab === 'foryou')      return _loadForYouTab();
567  if (tab === 'discover')    return _loadDiscoverTab();
568  if (tab === 'ranking')     return _loadRankingTab();
569  if (tab === 'readinglist') return _loadReadingListTab();
570}
571
572async function _loadForYouTab() {
573  // Scope all DOM queries to this panel. Several keys (mostLoved / mostRead /
574  // trending) are reused by the Ranking tab — without this scope, querySelector
575  // would write to whichever copy comes first in the DOM.
576  const panel = document.getElementById('bookTabForYou');
577  if (!panel) return;
578  // BX-2A — Continue Reading rail (logged-in only; self-hides when empty).
579  // Fire-and-forget so it loads in parallel with the recommendation rails.
580  _loadContinueReadingRail(panel);
581  // Show a loading state on every track immediately so the page never looks empty.
582  for (const s of _FORYOU_SECTIONS) {
583    const track = panel.querySelector(`.book-section-track[data-track="${s.key}"]`);
584    if (track) track.innerHTML = '<div class="loading">Loading…</div>';
585  }
586
587  // Overfetch 3× — gives the dedup pass plenty of headroom even when later
588  // rails get most of their top picks claimed by earlier rails.
589  const FETCH = _SECTION_ROW_SIZE * 3;
590  const results = await Promise.all(
591    _FORYOU_SECTIONS.map(s =>
592      s.fetch(FETCH).catch(err => {
593        console.warn(`[For You] section "${s.key}" failed:`, err);
594        return [];
595      })
596    )
597  );
598
599  // Claim books in priority order with SOFT dedup:
600  //   1. First pass: take un-claimed books only (the strict dedup).
601  //   2. If a rail ends up too sparse to be useful (< MIN_PER_RAIL), top it
602  //      up with its own already-claimed books — better to repeat a great
603  //      book in a category showcase than to leave the rail empty.
604  // This is the right trade for "category" rails like Most Loved / Completed,
605  // where the rail's whole job is "show me the best of this slice", and dies
606  // visually if dedup steals all its top picks.
607  const seen = new Set();
608  const MIN_PER_RAIL = 4;
609  results.forEach((books, i) => {
610    const pool = books || [];
611    const claimed = [];
612    const claimedIds = new Set();
613    // Pass 1 — strict dedup
614    for (const b of pool) {
615      if (!b || seen.has(b.id) || claimedIds.has(b.id)) continue;
616      claimed.push(b);
617      claimedIds.add(b.id);
618      seen.add(b.id);
619      if (claimed.length >= _SECTION_ROW_SIZE) break;
620    }
621    // Pass 2 — soft fill if the rail came up short
622    if (claimed.length < MIN_PER_RAIL) {
623      for (const b of pool) {
624        if (!b || claimedIds.has(b.id)) continue;
625        claimed.push(b);
626        claimedIds.add(b.id);
627        if (claimed.length >= _SECTION_ROW_SIZE) break;
628      }
629    }
630    _renderBookSection(_FORYOU_SECTIONS[i].key, claimed, _FORYOU_SECTIONS[i].empty, panel);
631  });
632}
633
634// Render-only helper — paints already-fetched books into a section track.
635// `scope` defaults to `document` but should be the parent tab panel when
636// the same section key exists in multiple tabs (For You and Ranking both
637// use mostLoved / mostRead / trending). Without scoping, the loaders write
638// to the first matching track in DOM order — usually the wrong tab.
639function _renderBookSection(sectionKey, books, emptyMsg, scope) {
640  const root = scope || document;
641  const track = root.querySelector(`.book-section-track[data-track="${sectionKey}"]`);
642  if (!track) return;
643  if (!books || !books.length) {
644    track.innerHTML = `<div class="book-section-empty">${escHTML(emptyMsg || 'Nothing here yet')}</div>`;
645    return;
646  }
647  track.innerHTML = '';
648  books.forEach(b => track.appendChild(_renderBookCardV2(b)));
649}
650
651// Hidden Gems: high engagement RATE, not absolute likes. A book with 8 likes
652// and 12 views is more of a "gem" than one with 50 likes and 600 views — the
653// first signals "almost everyone who reads it loves it". Server filter is
654// the same (low-views floor), but we re-rank client-side by likes/views ratio
655// to pick the *true* gems out of that pool.
656async function _fetchHiddenGems(limit = _SECTION_ROW_SIZE) {
657  // Overfetch — we need headroom because we re-rank below.
658  const raw = await fetchSupabaseBooks(0, Math.max(limit * 3, 24), 'most-liked', {
659    filter: q => q.gte('likes_count', 3).lt('views_count', 500),
660  });
661  return [...raw].sort((a, b) => {
662    // Smoothed ratio: floor views at 10 so a 5-view, 5-like fluke doesn't
663    // win over a 50-view, 40-like consistent favourite.
664    const rateA = (a.likes_count || 0) / Math.max(a.views_count || 0, 10);
665    const rateB = (b.likes_count || 0) / Math.max(b.views_count || 0, 10);
666    if (rateB !== rateA) return rateB - rateA;
667    return (b.likes_count || 0) - (a.likes_count || 0);
668  }).slice(0, limit);
669}
670
671// Quick Reads: short stories ranked by likes-PER-CHAPTER so a 1-chapter
672// masterpiece beats a 5-chapter draft. Density of love > sheer count.
673async function _fetchQuickReads(limit = _SECTION_ROW_SIZE) {
674  const raw = await fetchSupabaseBooks(0, Math.max(limit * 3, 24), 'most-liked', {
675    filter: q => q.gte('chapters_count', 1).lte('chapters_count', 5),
676  });
677  return [...raw].sort((a, b) => {
678    const densityA = (a.likes_count || 0) / Math.max(livePartCount(a), 1);
679    const densityB = (b.likes_count || 0) / Math.max(livePartCount(b), 1);
680    if (densityB !== densityA) return densityB - densityA;
681    return (b.likes_count || 0) - (a.likes_count || 0);
682  }).slice(0, limit);
683}
684
685// Weekly Featured = editor's picks first; if too few, top up with trending
686// (last-7-day hot) and finally with high-likes recent books, so the row
687// always feels alive even before a moderator curates anything.
688async function _fetchWeeklyFeaturedWithFallback(limit = _SECTION_ROW_SIZE) {
689  const target = limit;
690  const picks = await fetchSupabaseBooks(0, target, 'editors-pick');
691  if (picks.length >= target) return picks;
692
693  // Top up with trending (most likely to feel "weekly featured" without curation)
694  const fillers = await fetchSupabaseBooks(0, target, 'trending');
695  const seen = new Set(picks.map(b => b.id));
696  for (const b of fillers) {
697    if (picks.length >= target) break;
698    if (!seen.has(b.id)) { picks.push(b); seen.add(b.id); }
699  }
700  if (picks.length >= 3) return picks;
701
702  // Final fallback: most-loved books overall — guarantees a populated row.
703  const safetyNet = await fetchSupabaseBooks(0, target, 'most-liked');
704  for (const b of safetyNet) {
705    if (picks.length >= target) break;
706    if (!seen.has(b.id)) { picks.push(b); seen.add(b.id); }
707  }
708  return picks;
709}
710
711// "Recommended for You" — picks books matching the tags/genres the user has
712// previously read or liked. For brand-new users (no signal), gracefully
713// falls back to trending so the rail is always populated.
714async function _fetchRecommendedForUser(limit = _SECTION_ROW_SIZE) {
715  if (!_cfg.getCurrentUser()?.id) {
716    return await fetchSupabaseBooks(0, limit, 'most-liked');
717  }
718  try {
719    // Pull a small sample of the user's recent reads + likes to derive their taste.
720    const [{ data: reads }, { data: likes }] = await Promise.all([
721      // book_reads' timestamp column is `last_read_at` (composite PK
722       // user_id+book_id, no created_at — Codex P2 catch). Ordering by
723       // created_at silently returned rows in arbitrary order, which
724       // skewed the seedlist toward random history rather than recent.
725       supabase.from('book_reads').select('book_id').eq('user_id', _cfg.getCurrentUser().id).order('last_read_at', { ascending: false }).limit(20),
726      supabase.from('book_likes').select('book_id').eq('user_id', _cfg.getCurrentUser().id).order('created_at', { ascending: false }).limit(20),
727    ]);
728    const seedIds = [...new Set([...(reads || []), ...(likes || [])].map(r => r.book_id))];
729    if (!seedIds.length) {
730      return await fetchSupabaseBooks(0, limit, 'trending');
731    }
732    // Build a tag-weight map from those books.
733    const { data: seedBooks } = await supabase.from('books')
734      .select('genre, tags').in('id', seedIds);
735    const tagWeights = {};
736    for (const b of (seedBooks || [])) {
737      if (b.genre) tagWeights[b.genre] = (tagWeights[b.genre] || 0) + 2;
738      for (const t of (b.tags || [])) {
739        if (t) tagWeights[t] = (tagWeights[t] || 0) + 1;
740      }
741    }
742    const topTag = Object.entries(tagWeights).sort((a,b) => b[1] - a[1])[0]?.[0];
743    if (!topTag) return await fetchSupabaseBooks(0, limit, 'trending');
744
745    // Fetch top books in that taste, excluding ones the user already read/liked.
746    // Overfetch so we have headroom after excluding their seedlist.
747    const exclude = new Set(seedIds);
748    const candidates = await fetchSupabaseBooks(0, Math.max(limit * 2, 14), 'most-liked', { genre: topTag });
749    const fresh = candidates.filter(b => !exclude.has(b.id)).slice(0, limit);
750    if (fresh.length >= 3) return fresh;
751    return await fetchSupabaseBooks(0, limit, 'trending');
752  } catch (err) {
753    console.warn('Recommended-for-you failed, falling back:', err);
754    return await fetchSupabaseBooks(0, _SECTION_ROW_SIZE, 'trending');
755  }
756}
757
758async function _loadRankingTab() {
759  const panel = document.getElementById('bookTabRanking');
760  if (!panel) return;
761  _renderRankGenreChips();
762  await _loadRankingForGenre(_rankingActiveGenre);
763}
764
765function _renderRankGenreChips() {
766  const wrap = document.getElementById('rankGenreChips');
767  if (!wrap) return;
768  wrap.innerHTML = '';
769  for (const g of _RANKING_GENRES) {
770    const btn = document.createElement('button');
771    btn.type = 'button';
772    btn.className = 'rank-genre-chip' + (g.slug === _rankingActiveGenre ? ' active' : '');
773    btn.dataset.genre = g.slug;
774    btn.textContent = g.label;
775    btn.addEventListener('click', () => {
776      if (g.slug === _rankingActiveGenre) return;
777      _rankingActiveGenre = g.slug;
778      // Update active chip styling without a full chip-rail rebuild
779      wrap.querySelectorAll('.rank-genre-chip').forEach(c => {
780        c.classList.toggle('active', c.dataset.genre === g.slug);
781      });
782      _loadRankingForGenre(g.slug);
783    });
784    wrap.appendChild(btn);
785  }
786}
787
788async function _loadRankingForGenre(genreSlug) {
789  const list = document.getElementById('rankList');
790  if (!list) return;
791  const seq = ++_rankingSeq;
792  list.innerHTML = '<div class="loading">Loading rankings…</div>';
793
794  let books;
795  try {
796    // Top 100 by all-time VIEWS — highest read count wins #1, matching the
797    // mobile App. Genre filter is optional (empty string = All).
798    books = await fetchSupabaseBooks(0, 100, 'most-read', genreSlug ? { genre: genreSlug } : {});
799  } catch (err) {
800    if (seq !== _rankingSeq) return;
801    console.warn('[Ranking] load failed:', err);
802    list.innerHTML = '<div class="rank-empty">Couldn\'t load rankings. Try again.</div>';
803    return;
804  }
805  if (seq !== _rankingSeq) return; // user switched genre mid-flight
806
807  if (!books.length) {
808    list.innerHTML = '<div class="rank-empty">No books in this category yet.</div>';
809    return;
810  }
811
812  list.innerHTML = '';
813  books.forEach((book, idx) => {
814    list.appendChild(_renderRankCard(book, idx + 1));
815  });
816}
817
818// Single ranked-list row — cover left with numbered ribbon, info right.
819// Mirrors the mobile app's leaderboard card: title, description, status
820// badge, paid/free badge, and an icon-stats row.
821// ── BX-3A real ratings display (mobile + home-carousel parity) ───────────────
822// Uses the trigger-maintained books.ratings_avg / books.ratings_count aggregates
823// (the same real fields mobile and the web home carousel already use) — never a
824// likes_count synthesis. ratings_count === 0 → no rating: cards hide the stat
825// (matching the home carousel pill, which only renders when rating > 0); the
826// detail header shows "No ratings yet". Read-only — no writes, no book_ratings.
827function _ratingInfo(book) {
828  const count = Number(book?.ratings_count) || 0;
829  if (count <= 0) return { hasRating: false, count: 0, avg: '0.0' };
830  const avg = Math.max(0, Math.min(5, Number(book?.ratings_avg) || 0));
831  return { hasRating: true, count, avg: avg.toFixed(1) };
832}
833function _ratingDetailSpan(book) {
834  const r = _ratingInfo(book);
835  return r.hasRating
836    ? `<span><strong>★ ${r.avg}</strong> · ${r.count.toLocaleString()} rating${r.count === 1 ? '' : 's'}</span>`
837    : `<span>No ratings yet</span>`;
838}
839
840// ── BX-7A book reviews display-only (read-only; book_ratings.review) ─────────────
841// Lists individual review TEXT from book_ratings for the open (public) book — same
842// table/columns/filter mobile's fetchRecentReviews uses (book_ratings SELECT is
843// world-readable per RLS; reads only). NO writes, NO submit_book_rating RPC, NO
844// chapter/inline comments, NO composer. All UGC (review text + username + avatar URL)
845// is escaped via escHTML — never raw/un-escaped innerHTML. Complements the BX-3A
846// aggregate; does NOT touch ratings_avg/ratings_count, entitlement, sanitizeChapterHtml,
847// or any write path. Empty → "No reviews yet"; error → section stays hidden (non-fatal).
848function _reviewStars(n) {
849  const r = Math.max(0, Math.min(5, Math.round(Number(n) || 0)));
850  return '★'.repeat(r) + '☆'.repeat(5 - r);
851}
852function _renderBookReviewItem(row) {
853  const p = row.profiles || null;
854  const name = (p && p.username) ? String(p.username) : 'Unknown reader';
855  const avatarUrl = (p && p.avatar_url) ? String(p.avatar_url) : '';
856  const avatar = avatarUrl ? `<img src="${escHTML(avatarUrl)}" alt=""/>` : escHTML(initials(name));
857  const rating = Math.max(0, Math.min(5, Math.round(Number(row.rating) || 0)));
858  const when = _continueTimeAgo(row.created_at);
859  const text = String(row.review || '').trim();
860  return `
861    <div class="book-review-item" style="display:flex;gap:.65rem;padding:.7rem 0;border-top:1px solid var(--border,rgba(255,255,255,0.08))">
862      <div class="avatar sm">${avatar}</div>
863      <div style="flex:1;min-width:0">
864        <div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
865          <span style="font-weight:700">${escHTML(name)}</span>
866          <span title="${rating} out of 5" aria-label="${rating} out of 5" style="color:var(--accent,#fbbf24);font-size:.82rem;letter-spacing:.04em">${_reviewStars(rating)}</span>
867          ${when ? `<span style="color:var(--text2);font-size:.78rem">${escHTML(when)}</span>` : ''}
868        </div>
869        <div style="margin-top:.3rem;white-space:pre-wrap;overflow-wrap:anywhere">${escHTML(text)}</div>
870      </div>
871    </div>`;
872}
873async function _loadBookReviews(bookId) {
874  const section = document.getElementById('bookReviewsSection');
875  const list = document.getElementById('bookReviewsList');
876  if (!section || !list || !bookId) return;
877  try {
878    const { data, error } = await supabase
879      .from('book_ratings')
880      .select('rating, review, created_at, updated_at, profiles!book_ratings_user_id_fkey ( id, username, avatar_url, is_banned, role, roles, badge_role )')
881      .eq('book_id', bookId)
882      .not('review', 'is', null)
883      .order('created_at', { ascending: false })
884      .limit(10);
885    if (error) throw error;
886    // A slow response could land after the user opened a different book — ignore it.
887    if (!currentBookDetail || currentBookDetail.book?.id !== bookId) return;
888    const rows = (data || [])
889      .filter(r => !(r.profiles && r.profiles.is_banned === true))   // hide banned reviewers
890      .filter(r => String(r.review || '').trim().length > 0);        // drop empty/whitespace-only
891    list.innerHTML = rows.length
892      ? rows.map(_renderBookReviewItem).join('')
893      : `<div class="book-section-empty">No reviews yet.</div>`;
894    section.style.display = '';
895  } catch (e) {
896    section.style.display = 'none';   // non-fatal (mirrors loadBookActionState's silent catch)
897  }
898}
899
900// ── BX-7B-3 book-detail comment COUNTS (read-only; counts only, NO bodies) ──────────────────
901// Per-chapter top-level comment counts on the book-detail chapter rows + a book total in the
902// stats row. SAFETY: counts are derived ONLY for the chapter ids already listed on book detail
903// (currentBookDetail.chapters is `.eq('is_published', true)` → published chapters only, incl.
904// locked published — a count is non-content activity metadata for an already-listed chapter).
905// The query selects ONLY chapter_id (no content, no user_id, no profiles/chapter
905s join, no
906// chapter content) → zero UGC/XSS surface. Top-level only (parent_id null) = mobile-parity count;
907// replies are NOT counted (handled in the reader by BX-7B-2). No realtime; counts refresh on the
908// next book-detail render. Stale-guard mirrors _loadBookReviews. Non-fatal on error.
909async function _loadChapterCommentCounts(bookId) {
910  if (!bookId || !currentBookDetail) return;
911  const ids = (currentBookDetail.chapters || []).map(c => c.id).filter(Boolean);
912  if (!ids.length) return;
913  try {
914    const { data, error } = await supabase
915      .from('chapter_comments')
916      .select('chapter_id')                 // chapter_id ONLY — no content/user_id/joins
917      .in('chapter_id', ids)                // only the displayed published chapters
918      .is('parent_id', null)                // top-level only (mobile parity)
919      .limit(5000);
920    if (error) throw error;
921    // Ignore a slow response if the user opened a different book meanwhile.
922    if (!currentBookDetail || currentBookDetail.book?.id !== bookId) return;
923    const rows = data || [];
924    const counts = new Map();
925    for (const r of rows) { if (r.chapter_id) counts.set(r.chapter_id, (counts.get(r.chapter_id) || 0) + 1); }
926    const capped = rows.length >= 5000;     // backstop hit → total is a floor, show "5000+"
927    let total = 0;
928    // Inject per-chapter counts ONLY into rows already in the DOM for THIS book (match by data-chapter-id).
929    document.querySelectorAll('#bookDetailContent .chapter-row[data-chapter-id]').forEach(row => {
930      const n = counts.get(row.dataset.chapterId) || 0;
931      total += n;
932      const slot = row.querySelector('.chapter-row-cc');
933      if (slot) slot.textContent = n > 0 ? `💬 ${n.toLocaleString()}` : '';
934    });
935    const statEl = document.getElementById('bookCommentCountStat');
936    if (statEl && total > 0) {
937      // Number only (our own integer) — no UGC. Matches sibling "<strong>N</strong> label" spans.
938      statEl.innerHTML = `<strong>${capped ? '5000+' : total.toLocaleString()}</strong> comment${total === 1 ? '' : 's'}`;
939      statEl.style.display = '';
940    }
941  } catch (e) { /* non-fatal — leave counts blank */ }
942}
943
944function _renderRankCard(book, rank) {
945  const card = document.createElement('button');
946  card.type = 'button';
947  card.className = 'rank-card';
948  card.dataset.rank = String(rank);
949  card.dataset.bookId = book.id;
950  card.onclick = () => openBookDetail(book.id);
951
952  const initial = (book.title || '?').trim().charAt(0).toUpperCase();
953  // Pre-crop the source to 2:3 via _supabaseRatioCrop (same path the For
954  // You tab + home shelves use). For Supabase-hosted covers this rewrites
955  // the URL to /storage/v1/render/image/public/ + width=400&height=600
956  // &resize=cover so the source ARRIVES at the right ratio and CSS doesn't
957  // have to fight to zoom/letterbox it. For Bunny/external hosts it's a
958  // pass-through (the CSS object-fit/object-position then carries the load
959  // for those legacy covers).
960  const croppedCover = book.cover_url
961    ? _cfg._supabaseRatioCrop(_cfg._cleanCdnUrl(book.cover_url), { width: 400, height: 600 })
962    : '';
963  const cover = book.cover_url
964    ? `<img src="${escHTML(croppedCover)}" alt="" loading="lazy"/>`
965    : `<div class="rank-card-cover-placeholder">${escHTML(initial)}</div>`;
966
967  const isCompleted = (book.status || '').toLowerCase() === 'completed';
968  const isPaid = (book.lock_from_chapter || 0) > 0;
969
970  // Real DB aggregate rating (BX-3A) — books.ratings_avg/ratings_count, never likes.
971  const rated = _ratingInfo(book);
972  const views  = _cfg.formatCompact(book.views_count || 0);
973  const likes  = _cfg.formatCompact(book.likes_count || 0);
974  const chapters = livePartCount(book);
975
976  const desc = (book.description || '').trim() || 'No description yet.';
977
978  card.innerHTML = `
979    <div class="rank-card-ribbon">${rank}</div>
980    <div class="rank-card-cover">${cover}</div>
981    <div class="rank-card-body">
982      <h3 class="rank-card-title">${escHTML(book.title || 'Untitled')}</h3>
983      <p class="rank-card-desc">${escHTML(desc)}</p>
984      <div class="rank-card-badges">
985        ${isCompleted
986          ? '<span class="rank-card-badge rank-card-badge-completed">Completed</span>'
987          : '<span class="rank-card-badge rank-card-badge-ongoing">Ongoing</span>'}
988        ${isPaid ? '<span class="rank-card-badge rank-card-badge-paid">Paid</span>' : ''}
989        ${renderContentRatingBadge(book)}
990      </div>
991      <div class="rank-card-stats">
992        ${rated.hasRating ? `<span class="rank-card-stat rank-card-stat-rating"><span class="rank-card-stat-icon">★</span>${rated.avg}</span>` : ''}
993        <span class="rank-card-stat rank-card-stat-views"><span class="rank-card-stat-icon">👁</span>${views}</span>
994        <span class="rank-card-stat rank-card-stat-likes"><span class="rank-card-stat-icon">♥</span>${likes}</span>
995        <span class="rank-card-stat"><span class="rank-card-stat-icon">📋</span>${chapters}</span>
996      </div>
997    </div>`;
998  return card;
999}
1000
1001async function _loadDiscoverTab() {
1002  const wrap = document.getElementById('bookDiscoverGenres');
1003  if (!wrap) return;
1004  // Build the section skeletons so each row gets its own loading state.
1005  wrap.innerHTML = '';
1006  _DISCOVER_GENRES.forEach(g => {
1007    const sec = document.createElement('div');
1008    sec.className = 'book-section';
1009    const safeG = escHTML(g);
1010    const pretty = escHTML(prettyGenre(g));
1011    // Same head markup as For You / Ranking — keeps "See All" right-aligned
1012    // and the title styling consistent across every tab.
1013    sec.innerHTML = `
1014      <div class="book-section-head">
1015        <h2 class="book-section-title">${pretty}</h2>
1016        <button class="book-section-see-all" data-see-all="genre:${safeG}" type="button">See All</button>
1017      </div>
1018      <div class="book-section-track" data-track="genre:${safeG}"><div class="loading">Loading…</div></div>`;
1019    wrap.appendChild(sec);
1020  });
1021  // Hydrate each row in parallel.
1022  await Promise.all(_DISCOVER_GENRES.map(g => _loadDiscoverGenreRow(g)));
1023}
1024
1025async function _loadDiscoverGenreRow(genre) {
1026  // Find the entire section (not just the track) so we can hide it if empty —
1027  // mobile-app parity: a genre with zero books shouldn't take up space.
1028  const section = document.querySelector(`.book-section-track[data-track="genre:${genre}"]`)?.closest('.book-section');
1029  const track = section?.querySelector(`.book-section-track[data-track="genre:${genre}"]`);
1030  if (!track) return;
1031  try {
1032    const books = await fetchSupabaseBooks(0, _SECTION_ROW_SIZE, 'most-liked', { genre });
1033    if (!books.length) {
1034      // Hide the section entirely — cleaner than an empty-state placeholder.
1035      if (section) section.style.display = 'none';
1036      return;
1037    }
1038    track.innerHTML = '';
1039    books.forEach(b => track.appendChild(_renderBookCardV2(b)));
1040  } catch (err) {
1041    console.warn(`Discover genre "${genre}" failed:`, err);
1042    track.innerHTML = '<div class="book-section-empty">Couldn\'t load.</div>';
1043  }
1044}
1045
1046// ── Reading List tab (user-scoped collection grid) ────────────────────────
1047async function _loadCollectionTab({ table, fkName, gridId, emptyId, signedOutMsg, orderColumn }) {
1048  const grid = document.getElementById(gridId);
1049  const empty = document.getElementById(emptyId);
1050  if (!grid) return;
1051  if (!_cfg.getCurrentUser()?.id) {
1052    grid.innerHTML = `<div class="book-collection-empty"><h3>${escHTML(signedOutMsg)}</h3></div>`;
1053    if (empty) empty.style.display = 'none';
1054    return;
1055  }
1056  grid.innerHTML = '<div class="loading">Loading…</div>';
1057  if (empty) empty.style.display = 'none';
1058  try {
1059    // Join through the user's pivot table (book_reads / book_bookmarks) so we
1060    // both fetch the book row AND keep the user's own "latest first" order.
1061    //
1062    // CRITICAL: book_reads's timestamp column is `last_read_at`, NOT
1063    // `created_at` — ordering by created_at on book_reads throws "column
1064    // does not exist" and the whole tab errors out. The orderColumn arg
1065    // lets each caller name its own.
1066    const col = orderColumn || 'created_at';
1067    const { data, error } = await supabase.from(table)
1068      .select(`book_id, ${col}, books!${fkName}(${BOOK_CARD_SELECT})`)
1069      .eq('user_id', _cfg.getCurrentUser().id)
1070      .order(col, { ascending: false })
1071      .limit(60);
1072    if (error) throw error;
1073    const books = _normalizeBookRows(await hydrateCatalogBooks(supabase,filterReaderEligibleBooks((data || []).map(r => r.books))));
1074    if (!books.length) {
1075      grid.innerHTML = '';
1076      if (empty) empty.style.display = 'block';
1077      return;
1078    }
1079    // v2 cards (corner ribbon + rating + views) so Reading List looks
1080    // identical to every other rail across the books page.
1081    grid.innerHTML = '';
1082    books.forEach((b, i) => {
1083      const card = _renderBookCardV2(b);
1084      card.style.animationDelay = `${(i * 0.025).toFixed(3)}s`;
1085      grid.appendChild(card);
1086    });
1087  } catch (err) {
1088    console.error(`${table} load failed:`, err);
1089    grid.innerHTML = '<div class="book-collection-empty"><p>Couldn\'t load. Try again.</p></div>';
1090  }
1091}
1092
1093// (Library tab loader was removed alongside its DOM. The web's existing
1094// sidebar Bookmarks page is the canonical "Library" experience here.)
1095function _loadReadingListTab() {
1096  return _loadCollectionTab({
1097    table: 'book_bookmarks',
1098    fkName: 'book_bookmarks_book_id_fkey',
1099    gridId: 'bookReadingListGrid',
1100    emptyId: 'bookReadingListEmpty',
1101    signedOutMsg: 'Sign in to see your reading list',
1102    orderColumn: 'created_at',
1103  });
1104}
1105
1106// ── BX-2A Continue Reading rail (read-only; mobile parity) ───────────────────
1107// Logged-in-only horizontal rail of the user's recently-read books, sourced
1108// from the SHARED `book_reads` table — the same rows saveReadingProgress()
1109// upserts AFTER the paywall check. Read-only: no new writes, no new columns,
1110// no RPC. Resume routes through the EXISTING gated reader (openBookDetail
1111// deep-link → openChapterReader → get_chapter_content_if_entitled), so a
1112// locked/unentitled last chapter still hits the paywall and never leaks
1113// content. Scroll-position resume (last_scroll_pct, which web doesn't write)
1114// is intentionally NOT here — that's BX-2B.
1115function _continueTimeAgo(iso) {
1116  const t = Date.parse(iso);
1117  if (!Number.isFinite(t)) return '';
1118  const s = Math.max(0, (Date.now() - t) / 1000);
1119  if (s < 60) return 'just now';
1120  const m = Math.floor(s / 60);  if (m < 60)  return `${m}m ago`;
1121  const h = Math.floor(m / 60);  if (h < 24)  return `${h}h ago`;
1122  const d = Math.floor(h / 24);  if (d < 7)   return `${d}
1122d ago`;
1123  const w = Math.floor(d / 7);   if (w < 5)   return `${w}w ago`;
1124  const mo = Math.floor(d / 30); if (mo < 12) return `${mo}mo ago`;
1125  return `${Math.floor(d / 365)}y ago`;
1126}
1127
1128// Wraps the shared v2 card: overrides the click to resume the last chapter via
1129// the gated reader, and appends a "Ch. N • {time ago}" sublabel (reusing the
1130// existing .book-card-v2-author text style — no CSS change).
1131function _renderContinueReadingCard(b) {
1132  const card = _renderBookCardV2(b);
1133  const chNum = Number(b._lastChapterNumber);
1134  const hasCh = Number.isFinite(chNum) && chNum > 0;
1135  card.onclick = () => openBookDetail(b.id, hasCh ? { chapter: chNum } : {});
1136  const authorEl = card.querySelector('.book-card-v2-author');
1137  if (authorEl) {
1138    const parts = [];
1139    if (hasCh) parts.push(`Ch. ${chNum}`);
1140    const ago = _continueTimeAgo(b._lastReadAt);
1141    if (ago) parts.push(ago);
1142    if (parts.length) {
1143      const sub = document.createElement('p');
1144      sub.className = 'book-card-v2-author';
1145      sub.textContent = parts.join(' • ');
1146      authorEl.insertAdjacentElement('afterend', sub);
1147    }
1148  }
1149  return card;
1150}
1151
1152async function _loadContinueReadingRail(panel) {
1153  const section = panel.querySelector('.book-section[data-section="continueReading"]');
1154  if (!section) return;
1155  const track = section.querySelector('.book-section-track[data-track="continueReading"]');
1156  // Logged-out → never query personal data; keep the rail hidden.
1157  if (!_cfg.getCurrentUser()?.id) { section.style.display = 'none'; return; }
1158  try {
1159    // Direct RLS read (book_reads policy reads_self = own rows only). Join the
1160    // book via its FK so one query renders the shelf — same pattern as
1161    // _loadCollectionTab. No RPC; never touches chapter_reads.
1162    const { data, error } = await supabase.from('book_reads')
1163      .select(`book_id, last_chapter_number, last_read_at, books!book_reads_book_id_fkey(${BOOK_CARD_SELECT})`)
1164      .eq('user_id', _cfg.getCurrentUser().id)
1165      .order('last_read_at', { ascending: false })
1166      .limit(12);
1167    if (error) throw error;
1168    // Drop deleted (null join) + anything not reader-eligible (private, hidden,
1169    // draft/paused, or banned author); carry per-book progress (last chapter +
1170    // last read time) onto the normalized book. is_public/is_hidden now come
1171    // from BOOK_CARD_SELECT, so the old explicit suffix was dropped.
1172    const rows = (data || []).filter(r => isReaderEligibleBook(r.books));
1173    const books = rows.map(r => {
1174      const b = _normalizeBookRows([r.books])[0];
1175      if (!b) return null;
1176      b._lastChapterNumber = r.last_chapter_number;
1177      b._lastReadAt = r.last_read_at;
1178      return b;
1179    }).filter(Boolean);
1180    if (!books.length) { section.style.display = 'none'; return; }  // empty → hide
1181    if (track) {
1182      track.innerHTML = '';
1183      books.forEach((b, i) => {
1184        const card = _renderContinueReadingCard(b);
1185        card.style.animationDelay = `${(i * 0.025).toFixed(3)}s`;
1186        track.appendChild(card);
1187      });
1188    }
1189    section.style.display = '';  // reveal now that we have items
1190  } catch (err) {
1191    console.warn('[Continue Reading] load failed:', err?.message);
1192    section.style.display = 'none';  // fail closed — never show a broken rail
1193  }
1194}
1195
1196// ── BX-1 book metadata badges (status / Free-Paid / content rating) ──────────
1197// Render-only pills mirroring mobile's book metadata cues (BookTag /
1198// BookInfoStats). All values come straight from existing books columns —
1199// no entitlement/unlock logic here. Drafts never get a public status badge
1200// (and public lists already exclude drafts at query time).
1201function renderBookStatusBadge(book) {
1202  const s = (book?.status || '').toLowerCase();
1203  if (s === 'completed') return '<span class="book-meta-badge book-meta-badge-completed">Completed</span>';
1204  if (s === 'ongoing')   return '<span class="book-meta-badge book-meta-badge-ongoing">Ongoing</span>';
1205  return ''; // draft/unknown → no public badge
1206}
1207function renderBookAccessBadge(book, chapters) {
1208  // Cards: Paid if lock_from_chapter >
1208 0. Detail (chapters loaded): also any chapter.is_locked.
1209  let isPaid = (book?.lock_from_chapter || 0) > 0;
1210  if (!isPaid && Array.isArray(chapters)) isPaid = chapters.some(c => c && (c.is_locked === true || c.isLocked === true));
1211  return isPaid
1212    ? '<span class="book-meta-badge book-meta-badge-paid">Paid</span>'
1213    : '<span class="book-meta-badge book-meta-badge-free">Free</span>';
1214}
1215function renderContentRatingBadge(book) {
1216  const raw = (book?.content_rating || '').toString().trim();
1217  if (!raw) return ''; // null/empty → no badge; never default to "Rated PG"
1218  const label = /^rated\b/i.test(raw) ? raw : ('Rated ' + raw);
1219  const mature = /18|mature|adult|nsfw|explicit|nc-?17|\bx\b/i.test(raw);
1220  return `<span class="book-meta-badge ${mature ? 'book-meta-badge-rating-mature' : 'book-meta-badge-rating'}">${escHTML(label)}</span>`;
1221}
1222function _renderBookMetaBadges(book, opts = {}) {
1223  const parts = [isAiGeneratedBook(book) ? `<span class="book-meta-badge book-meta-badge-ai">${AI_GENERATED_COPY.pill}</span>` : '', renderBookStatusBadge(book)];
1224  if (opts.access !== false) parts.push(renderBookAccessBadge(book, opts.chapters));
1225  parts.push(renderContentRatingBadge(book));
1226  const html = parts.filter(Boolean).join('');
1227  return html ? `<div class="book-meta-badges">${html}</div>` : '';
1228}
1229// Share a canonical book link — mirrors mobile's native share (book-info.jsx)
1230// with a clipboard fallback. No analytics/tracking.
1231function _shareBook(bookId, title) {
1232  const realId = String(bookId || '').startsWith('sb_') ? String(bookId).slice(3) : (bookId || '');
1233  const url = `${window.location.origin}/books/${realId}`;
1234  if (navigator.share) {
1235    navigator.share({ title: title || 'Selebox', text: 'Check out this book!', url }).catch(() => {});
1236    return;
1237  }
1238  if (navigator.clipboard?.writeText) {
1239    navigator.clipboard.writeText(url).then(
1240      () => toast('Link copied!', 'success'),
1241      () => toast("Couldn't copy the link", 'error'),
1242    );
1243    return;
1244  }
1245  toast("Sharing isn't supported on this browser", 'error');
1246}
1247
1248// ── v2 book card (matches mobile look: cover + corner badge + stats) ───────
1249function _renderBookCardV2(b) {
1250  const card = document.createElement('button');
1251  card.type = 'button';
1252  card.className = 'book-card-v2';
1253  card.dataset.bookId = b.id;
1254  card.onclick = () => openBookDetail(b.id);
1255
1256  const initialLetter = (b.title || '?').trim().charAt(0).toUpperCase();
1257  // Use Supabase's server-side image transform to crop the cover to a
1258  // clean 2:3 (400x600 px) — same trick the home shelves use. Without
1259  // this, 9:16 video-shaped source covers would render in their
1260  // native tall aspect inside our 2:3 container, looking stretched.
1261  // Pass-through for non-Supabase URLs (Appwrite/Bunny).
1262  const croppedCover = b.cover_url
1263    ? _cfg._supabaseRatioCrop(_cfg._cleanCdnUrl(b.cover_url), { width: 400, height: 600 })
1264    : null;
1265  const cover = croppedCover
1266    ? `<img src="${escHTML(croppedCover)}" alt="" loading="lazy" onerror="this.style.display='none'"/>`
1267    : `<div class="book-card-v2-cover-placeholder">${escHTML(initialLetter)}</div>`;
1268  const isPaid = (b.lock_from_chapter || 0) > 0;
1269  const author = b.author?.username || b.profiles?.username || 'Unknown';
1270  // Real DB aggregate rating (BX-3A) — books.ratings_avg/ratings_count (same
1271  // fields the home carousel + mobile use); no likes_count synthesis.
1272  const rated = _ratingInfo(b);
1273  const views = _cfg.formatCompact(b.views_count || 0);
1274
1275  card.innerHTML = `
1276    <div class="book-card-v2-cover">
1277      ${cover}
1278      <div class="book-card-v2-badge ${isPaid ? 'book-card-v2-badge-paid' : 'book-card-v2-badge-free'}">${isPaid ? 'Paid' : 'Free'}</div>
1279    </div>
1280    <div class="book-card-v2-body">
1281      <h3 class="book-card-v2-title">${escHTML(b.title || 'Untitled')}</h3>
1282      <p class="book-card-v2-author">by ${escHTML(author)}${renderDisplayBadge(b.author || b.profiles)}</p>
1283      ${_renderBookMetaBadges(b, { access: false })}
1284      <div class="book-card-v2-stats">
1285        ${rated.hasRating ? `<span class="book-card-v2-stat book-card-v2-stat-rating">★ ${rated.avg}</span>` : ''}
1286        <span class="book-card-v2-stat book-card-v2-stat-views">👁 ${views}</span>
1287      </div>
1288    </div>`;
1289  return card;
1290}
1291
1292async function _openBookSeeAll(key) {
1293  let cfg = _SEE_ALL_MAP[key];
1294  let genre = null;
1295  if (!cfg && key && key.startsWith('genre:')) {
1296    genre = key.slice('genre:'.length).replace(/[^a-z0-9-]/gi, '');
1297    cfg = { sort: 'most-liked', title: prettyGenre(genre) };
1298  }
1299  // Charles caught a "See All shows nothing" bug — when the click target's
1300  // data-see-all key didn't match any _SEE_ALL_MAP entry AND didn't start
1301  // with `genre:`, this `return` left the user in a broken state (the
1302  // button click did nothing, no loading, no error). Log it so we notice
1303  // any new section keys we forget to register in _SEE_ALL_MAP.
1304  if (!cfg) {
1305    console.warn(`[see-all] unknown data-see-all key: "${key}" — add it to _SEE_ALL_MAP or use a genre: prefix`);
1306    return;
1307  }
1308
1309  _seeAllSeq++;
1310  _seeAllSort = cfg.sort;
1311  _seeAllGenre = genre;
1312  _seeAllFilter = cfg.filter || null;
1313  _seeAllOffset = 0;
1314  _seeAllHasMore = true;
1315  if (_seeAllObserver) { _seeAllObserver.disconnect(); _seeAllObserver = null; }
1316
1317  document.querySelectorAll('.book-tab-panel').forEach(p => p.style.display = 'none');
1318  const seeAll = document.getElementById('bookSeeAllView');
1319  if (!seeAll) return;
1320  seeAll.style.display = 'block';
1321  document.getElementById('bookSeeAllTitle').textContent = cfg.title;
1322  document.getElementById('bookSeeAllGrid').innerHTML = '<div class="loading">Loading…</div>';
1323  const sentinel = document.getElementById('bookSeeAllSentinel');
1324  if (sentinel) sentinel.style.display = 'none';
1325
1326  window.scrollTo({ top: 0, behavior: 'instant' });
1327  await _loadMoreSeeAllBooks(true);
1328}
1329
1330async function _loadMoreSeeAllBooks(initial = false) {
1331  if (_seeAllLoading || (!_seeAllHasMore && !initial)) return;
1332  _seeAllLoading = true;
1333  const seq = _seeAllSeq;
1334  const grid = document.getElementById('bookSeeAllGrid');
1335  const sentinel = document.getElementById('bookSeeAllSentinel');
1336  const limit = 40;
1337
1338  try {
1339    // One fetcher path, three optional shapes — sort-only, genre-filtered, or
1340    // predicate-filtered (Hidden Gems / Quick Reads). All three combine cleanly.
1341    const opts = {};
1342    if (_seeAllGenre)  opts.genre  = _seeAllGenre;
1343    if (_seeAllFilter) opts.filter = _seeAllFilter;
1344    const books = await fetchSupabaseBooks(_seeAllOffset, limit, _seeAllSort, opts);
1345
1346    if (seq !== _seeAllSeq) return; // user opened a different See-All
1347
1348    if (initial) grid.innerHTML = '';
1349    if (initial && !books.length) {
1350      grid.innerHTML = '<div class="book-collection-empty"><p>Nothing here yet.</p></div>';
1351      _seeAllHasMore = false;
1352      if (sentinel) sentinel.style.display = 'none';
1353      return;
1354    }
1355
1356    books.forEach((b, i) => {
1357      const card = renderBookCard(b);
1358      card.style.animationDelay = `${(i * 0.02).toFixed(3)}s`;
1359      grid.appendChild(card);
1360    });
1361    _seeAllOffset += books.length;
1362
1363    if (books.length < limit) {
1364      _seeAllHasMore = false;
1365      if (sentinel) {
1366        sentinel.style.display = 'block';
1367        sentinel.innerHTML = `<div class="book-grid-end-msg">You've reached the end · ${_seeAllOffset.toLocaleString()} books</div>`;
1368      }
1369      if (_seeAllObserver) { _seeAllObserver.disconnect(); _seeAllObserver = null; }
1370    } else {
1371      if (sentinel) {
1372        sentinel.style.display = 'block';
1373        sentinel.innerHTML = '<div class="book-grid-loadmore">Loading more books…</div>';
1374      }
1375      _setupSeeAllInfiniteScroll();
1376    }
1377  } catch (err) {
1378    if (seq !== _seeAllSeq) return;
1379    console.error('See-all load failed:', err);
1380    if (initial) grid.innerHTML = '<div class="book-collection-empty"><p>Couldn\'t load. Try again.</p></div>';
1381    if (sentinel) sentinel.innerHTML = '<div class="book-grid-end-msg">Couldn\'t load more.</div>';
1382  } finally {
1383    _seeAllLoading = false;
1384  }
1385}
1386
1387function _setupSeeAllInfiniteScroll() {
1388  if (_seeAllObserver || !('IntersectionObserver' in window)) return;
1389  const sentinel = document.getElementById('bookSeeAllSentinel');
1390  if (!sentinel) return;
1391  _seeAllObserver = new IntersectionObserver(entries => {
1392    if (entries[0].isIntersecting) _loadMoreSeeAllBooks(false);
1393  }, { root: null, rootMargin: '600px 0px', threshold: 0.01 });
1394  _seeAllObserver.observe(sentinel);
1395}
1396
1397// (loadBooks / loadMoreBooks removed — the books page is now tabbed, with
1398// curated sections rendered by _loadForYouTab / _loadDiscoverTab / etc., and
1399// the See-All sub-view's pagination is handled by _loadMoreSeeAllBooks.)
1400
1401// applyBookFilter — kept as a small helper. Currently unused by the v2 flow
1402// (server-side genre filter handles it for the See-All view), but cheap to
1403// keep around for future call sites.
1404function applyBookFilter(list, genre) {
1405  const filter = genre || '';
1406  if (!filter) return [...list];
1407  const filterLower = filter.toLowerCase();
1408  const filterWords = filterLower.replace(/-/g, ' ');
1409  return list.filter(b => {
1410    if (b.genre === filter) return true;
1411    return (b.tags || []).some(t => {
1412      const tagLower = (t || '').toLowerCase();
1413      return tagLower === filterLower || tagLower === filterWords;
1414    });
1415  });
1416}
1417
1418// ── Book search (filters the currently-loaded book cache) ──
1419// Searches: title, description, tags, genre, author/uploader name.
1420// `#tag` prefix restricts to tag-only matches. Suspends infinite scroll while active.
1421function searchBooks(query) {
1422  query = (query || '').trim();
1423  if (!query) return allBooksCache;
1424
1425  const hashtagMatch = query.match(/^#(\w+)/);
1426  const isHashtag = !!hashtagMatch;
1427  const cleanQuery = _cfg.normalizeForSearch(isHashtag ? hashtagMatch[1] : query);
1428
1429  return allBooksCache.filter(b => {
1430    if (isHashtag) {
1431      return (b.tags || []).some(t => _cfg.normalizeForSearch(t).includes(cleanQuery));
1432    }
1433
1434    const title  = _cfg.normalizeForSearch(b.title);
1435    const desc   = _cfg.normalizeForSearch(b.description);
1436    const tags   = _cfg.normalizeForSearch((b.tags || []).join(' '));
1437    const genre  = _cfg.normalizeForSearch((b.genre || '').replace(/-/g, ' '));
1438    const author = _cfg.normalizeForSearch(b.profiles?.username || b.author?.username || '');
1439
1440    return title.includes(cleanQuery)
1441        || desc.includes(cleanQuery)
1442        || tags.includes(cleanQuery)
1443        || genre.includes(cleanQuery)
1444        || author.includes(cleanQuery);
1445  });
1446}
1447
1448async function runBookSearch() {
1449  const mobileController = _getBooksMobileController();
1450  if (mobileController) { await mobileController.search(activeBookSearchQuery); return; }
1451  // The v2 books page has no flat grid — search results render into the
1452  // See-All sub-view, which already has its own grid + infinite scroll.
1453  const grid = document.getElementById('bookSeeAllGrid');
1454  const seeAllRoot = document.getElementById('bookSeeAllView');
1455  const titleEl = document.getElementById('bookSeeAllTitle');
1456  if (!grid || !seeAllRoot || !titleEl) return;
1457
1458  // Defensive: only render into the books page when it's actually visible.
1459  // (getSearchContext now restricts books-context to the listing page, but
1460  // this is a belt-and-braces check in case future code changes that.)
1461  if (bookPage && bookPage.style.display === 'none') return;
1462
1463  if (!activeBookSearchQuery.trim()) {
1464    // Empty query → close the search view and return to the active tab panel
1465    seeAllRoot.style.display = 'none';
1466    document.querySelectorAll('.book-tab-panel').forEach(p => {
1467      const isActive = p.dataset.bookPanel === _activeBookTab;
1468      p.style.display = isActive ? '' : 'none';
1469      p.classList.toggle('active', isActive);
1470    });
1471    return;
1472  }
1473
1474  // Show the See-All view and treat search as its own kind of "list"
1475  document.querySelectorAll('.book-tab-panel').forEach(p => p.style.display = 'none');
1476  seeAllRoot.style.display = 'block';
1477  titleEl.textContent = `Search: "${activeBookSearchQuery}"`;
1478  // Disable any pending see-all infinite scroll — search isn't paginated here
1479  if (_seeAllObserver) { _seeAllObserver.disconnect(); _seeAllObserver = null; }
1480  _seeAllHasMore = false;
1481  const sentinel = document.getElementById('bookSeeAllSentinel');
1482  if (sentinel) sentinel.style.display = 'none';
1483  grid.innerHTML = '<div class="loading">Searching books…</div>';
1484
1485  const savedQuery = activeBookSearchQuery;
1486  const { books: serverHits, writers } = await fetchBooksServerSearch(savedQuery)
1487    .catch(() => ({ books: [], writers: [] }));
1488
1489  // Stale-query guard: ignore if user kept typing
1490  if (activeBookSearchQuery !== savedQuery) return;
1491
1492  grid.innerHTML = '';
1493
1494  // ── Writer channel cards (YouTube-style, above the books) ──
1495  if (writers && writers.length) {
1496    const header = document.createElement('div');
1497    header.className = 'video-creators-header';
1498    header.style.gridColumn = '1 / -1';
1499    header.textContent = writers.length === 1 ? 'Writer' : 'Writers';
1500    grid.appendChild(header);
1501
1502    const row = document.createElement('div');
1503    row.className = 'video-creators-row';
1504    row.style.gridColumn = '1 / -1';
1505    writers.forEach(w => row.appendChild(renderWriterChannelCard(w)));
1506    grid.appendChild(row);
1507
1508    if (serverHits.length) {
1509      const booksHeader = document.createElement('div');
1510      booksHeader.className = 'video-creators-header';
1511      booksHeader.style.gridColumn = '1 / -1';
1512      booksHeader.textContent = 'Books';
1513      grid.appendChild(booksHeader);
1514    }
1515  }
1516
1517  if (!serverHits.length) {
1518    const emptyEl = document.createElement('div');
1519    emptyEl.className = 'book-collection-empty';
1520    emptyEl.style.gridColumn = '1 / -1';
1521    emptyEl.innerHTML = (writers && writers.length)
1522      ? '<h3>No books found</h3><p>The writers above match — open one of their pages to see their work.</p>'
1523      : '<h3>No books found</h3><p>Try a different keyword, author, or #tag.</p>';
1524    grid.appendChild(emptyEl);
1525    return;
1526  }
1527
1528  serverHits.forEach((b, i) => {
1529    const card = renderBookCard(b);
1530    card.style.animationDelay = `${(i * 0.02).toFixed(3)}s`;
1531    grid.appendChild(card);
1532  });
1533}
1534
1535// Writer channel card — same shape as the video creator card so the books
1536// page reuses the existing .creator-channel-card styling.
1537function renderWriterChannelCard(writer) {
1538  const card = document.createElement('button');
1539  card.className = 'creator-channel-card';
1540  card.type = 'button';
1541  card.onclick = () => _cfg.openProfile(writer.id);
1542
1543  const initial = (writer.username || '?').trim().charAt(0).toUpperCase();
1544  const avatar = writer.avatar_url
1545    ? `<img src="${escHTML(writer.avatar_url)}" alt=""/>`
1546    : `<div class="creator-channel-avatar-placeholder">${initial}</div>`;
1547
1548  const booksLabel = writer.books_count === 1 ? '1 book' : `${(writer.books_count || 0).toLocaleString()} books`;
1549
1550  card.innerHTML = `
1551    <div class="creator-channel-avatar">${avatar}</div>
1552    <div class="creator-channel-info">
1553      <div class="creator-channel-name">${escHTML(writer.username || 'Unknown')}</div>
1554      <div class="creator-channel-meta">${booksLabel}</div>
1555      ${writer.bio ? `<div class="creator-channel-bio">${escHTML(writer.bio.slice(0, 90))}${writer.bio.length > 90 ? '…' : ''}</div>` : ''}
1556    </div>
1557    <div class="creator-channel-cta">View profile →</div>
1558  `;
1559  return card;
1560}
1561
1562// Normalise a raw row into the shape the rest of the app expects.
1563// Filters out banned-author rows; returns null for those (caller filters).
1564function _normalizeBookRow(row) {
1565  if (!row) return null;
1566  if (row.profiles?.is_banned) return null;
1567  return {
1568    ...row,
1569    $id: 'sb_' + row.id,
1570    author: row.profiles
1571      ? { ...row.profiles, avatar: row.profiles.avatar_url }
1572      : null,
1573  };
1574}
1575
1576function _normalizeBookRows(rows) {
1577  return (rows || []).map(_normalizeBookRow).filter(Boolean);
1578}
1579
1580// Single fetcher for every browse-style book list. Supports server-side
1581// sort (via _BOOK_SORT_PIPELINES) and an optional genre filter that matches
1582// against either the genre column or the tags array.
1583//
1584//   fetchSupabaseBooks(0, 12, 'most-liked')                  → top 12 by likes
1585//   fetchSupabaseBooks(0, 12, 'most-liked', { genre: 'sci-fi' }) → top 12 sci-fi
1586async function fetchSupabaseBooks(offset = 0, limit = 80, sortBy = bookSortBy, opts = {}) {
1587  try {
1588    let q = supabase.from('books').select(BOOK_LIST_SELECT)
1589      .eq('is_public', true).eq('is_hidden', false);
1590
1591    // The Completed pipeline applies its own .eq('status','completed'), so the
1592    // status filter is redundant there. Every OTHER sort — including Editor's
1593    // Pick — must be constrained to reader-visible statuses so a draft/paused
1594    // book can never surface through a discovery row. (Belt-and-suspenders:
1595    // filterReaderEligibleBooks below re-checks status + banned authors.)
1596    if (sortBy !== 'completed') {
1597      q = q.in('status', PUBLIC_READER_BOOK_STATUSES);
1598    }
1599
1600    // Optional genre filter — matches the dedicated `genre` column AND
1601    // any of the common shapes that show up in the `tags` array. The
1602    // slug ("hot-romance") is what genres are stored as on the `genre`
1603    // column, but authors typically tag books with the display form
1604    // ("Hot Romance" / "hot romance"). Pre-fix this only matched the
1605    // hyphenated slug in tags, so a book genre-tagged "Hot Romance"
1606    // would slip through every Discover/See-All row because:
1607    //   - genre.eq.hot-romance     → only the explicit slug
1608    //   - tags.cs.{hot-romance}    → only tag-with-hyphen
1609    // (Charles flagged Hot Romance rows showing 2 of N expected books.)
1610    // We now also match the spaced-display form (both casings) by
1611    // expanding the .or() clause to cover the realistic shapes.
1612    //
1613    // Strip anything but [a-z0-9-] from the slug defensively so a
1614    // malformed input can't break the .or() clause syntax. We derive
1615    // the display variants from the cleaned slug so they stay in sync.
1616    if (opts.genre) {
1617      const safeG = String(opts.genre).toLowerCase().replace(/[^a-z0-9-]/g, '');
1618      if (safeG) {
1619        const spaced = safeG.replace(/-/g, ' ');                    // "hot romance"
1620        const titled = spaced.replace(/\b\w/g, c => c.toUpperCase());// "Hot Romance"
1621        // tags.cs.{...} requires literal text in the array; we pass
1622        // both the lowercased + Title-Cased variants because authors
1623        // shape-shift between them. genre.eq matches the canonical
1624        // slug column. PostgREST allows a comma-separated list inside
1625        // .or() — each clause is an independent match.
1626        q = q.or(
1627          `genre.eq.${safeG},` +
1628          `tags.cs.{${safeG}},` +
1629          `tags.cs.{${spaced}},` +
1630          `tags.cs.{${titled}}`
1631        );
1632      }
1633    }
1634
1635    // Optional custom predicate — caller-supplied function that adds extra
1636    // filters before the sort + range are applied. Used by Hidden Gems
1637    // (low-views + min-likes) and Quick Reads (short chapter count) so
1638    // those callers don't need their own duplicated query function.
1639    if (typeof opts.filter === 'function') q = opts.filter(q);
1640
1641    const pipeline = _BOOK_SORT_PIPELINES[sortBy] || _BOOK_SORT_PIPELINES.trending;
1642    const { data, error } = await pipeline(q).range(offset, offset + limit - 1);
1643    if (error) {
1644      if (opts.throwOnError) throw error;
1645      console.error('Supabase books fetch error:', error);
1646      return [];
1647    }
1648    return _normalizeBookRows(await hydrateCatalogBooks(supabase,filterReaderEligibleBooks(data)));
1649  } catch (err) {
1650    if (opts.throwOnError) throw err;
1651    console.error('fetchSupabaseBooks failed:', err);
1652    return [];
1653  }
1654}
1655
1656// Server-side book search — hits both content (title/description) AND
1657// matching author usernames. Used as a fallback when the local cache misses
1658// because pagination only loaded the first 80 books.
1659async function fetchBooksServerSearch(query, { throwOnError = false, sort = 'trending', genre = '' } = {}) {
1660  // Sanitize FIRST — strips comma/parens/quotes that break PostgREST .or() —
1661  // THEN escape ilike wildcards. Without sanitize, queries like `Romeo, Juliet`
1662  // or `What's next?` returned 0 rows because the OR clause was malformed.
1663  const safeQ = _cfg.sanitizeSearchQuery(query);
1664  if (!safeQ) return { books: [], writers: [] };
1665  const q = _cfg.escapeIlike(safeQ);
1666  try {
1667    const safeGenre = String(genre || '').toLowerCase().replace(/[^a-z0-9-]/g, '');
1668    const tagTerm = String(safeQ).replace(/[^a-zA-Z0-9 -]/g, '').trim();
1669    const scope = (builder) => {
1670      let scoped = builder.eq('is_public', true).eq('is_hidden', false);
1671      scoped = sort === 'completed' ? scoped.eq('status', 'completed') : scoped.in('status', PUBLIC_READER_BOOK_STATUSES);
1672      if (safeGenre) {
1673        const spaced = safeGenre.replace(/-/g, ' ');
1674        const titled = spaced.replace(/\b\w/g, c => c.toUpperCase());
1675        scoped = scoped.or(`genre.eq.${safeGenre},tags.cs.{${safeGenre}},tags.cs.{${spaced}},tags.cs.{${titled}}`);
1676      }
1677      return (_BOOK_SORT_PIPELINES[sort] || _BOOK_SORT_PIPELINES.trending)(scoped);
1678    };
1679    const textPredicates = [`title.ilike.%${q}%`, `description.ilike.%${q}%`, `genre.ilike.%${q}%`];
1680    if (tagTerm) {
1681      const titledTagTerm = tagTerm.toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
1682      textPredicates.push(`tags.cs.{${tagTerm}}`, `tags.cs.{${tagTerm.toLowerCase()}}`, `tags.cs.{${titledTagTerm}}`);
1683    }
1684    // Two parallel queries: content match + author username match
1685    const [contentRes, authorRes] = await Promise.all([
1686      scope(supabase.from('books').select(BOOK_LIST_SELECT).or(textPredicates.join(','))).limit(80),
1687      // Find matching author profiles (also surfaced as the YouTube-style writers row)
1688      supabase.from('profiles')
1689        .select('id, username, avatar_url, bio, is_banned')
1690        .ilike('username', `%${q}%`).eq('is_banned', false)
1691        .limit(8)
1692    ]);
1693    if (contentRes.error) throw contentRes.error;
1694    if (authorRes.error) throw authorRes.error;
1695
1696    let authorBooks = [];
1697    const matchingWriters = authorRes.data || [];
1698    if (matchingWriters.length) {
1699      const ids = matchingWriters.map(p => p.id);
1700      const authorBookRes = await scope(supabase.from('books').select(BOOK_LIST_SELECT).in('author_id', ids)).limit(80);
1701      if (authorBookRes.error) throw authorBookRes.error;
1702      const { data } = authorBookRes;
1703      authorBooks = data || [];
1704    }
1705
1706    // Merge + dedupe + normalise (drops banned-author rows along the way).
1707    const seen = new Set();
1708    const merged = [...(contentRes.data || []), ...authorBooks].filter(b => {
1709      if (seen.has(b.id)) return false;
1710      seen.add(b.id);
1711      return true;
1712    });
1713    const number = (value) => Number(value) || 0;
1714    const time = (value) => Number.isFinite(Date.parse(value)) ? Date.parse(value) : 0;
1715    const sorted = merged.sort((a, b) => {
1716      if (sort === 'most-liked' || sort === 'completed') return number(b.likes_count) - number(a.likes_count) || time(b.created_at) - time(a.created_at);
1717      if (sort === 'recent') return time(b.published_at || b.created_at) - time(a.published_at || a.created_at);
1718      return number(b.trending_score) - number(a.trending_score) || number(b.likes_count) - number(a.likes_count) || time(b.created_at) - time(a.created_at);
1719    });
1720    const books = _normalizeBookRows(await hydrateCatalogBooks(supabase,sorted.slice(0, 80)));
1721
1722    // Decorate writers with their book count from the returned set
1723    const writerStats = new Map();
1724    for (const b of books) {
1725      if (!b.author?.id) continue;
1726      writerStats.set(b.author.id, (writerStats.get(b.author.id) || 0) + 1);
1727    }
1728    const writers = matchingWriters.map(p => ({
1729      id: p.id,
1730      username: p.username,
1731      avatar_url: p.avatar_url,
1732      bio: p.bio || '',
1733      books_count: writerStats.get(p.id) || 0,
1734    }));
1735    return { books, writers };
1736  } catch (err) {
1737    if (throwOnError) throw err;
1738    console.warn('fetchBooksServerSearch failed:', err);
1739    return { books: [], writers: [] };
1740  }
1741}
1742
1743// (renderBooks removed — the v2 books page renders into per-tab DOM targets:
1744// section tracks for For You/Discover/Ranking, a dedicated grid for the
1745// Reading List collection, and bookSeeAllGrid for the See-All sub-view.)
1746
1747function renderBookCard(b) {
1748  const card = document.createElement('button');
1749  card.className = 'book-card';
1750  card.dataset.bookId = b.id;
1751  card.onclick = () => openBookDetail(b.id);
1752
1753  const authorName = b.author?.username || 'Unknown author';
1754  const initialLetter = (b.title || '?').trim().charAt(0).toUpperCase();
1755  // Pre-crop the cover to 2:3 via _supabaseRatioCrop (matches the For You
1756  // tab + home shelves + Ranking card). Supabase covers get the
1757  // /render/image/public/ rewrite with width=400&height=600&resize=cover.
1758  // Non-Supabase covers (Bunny, external) pass through and rely on
1759  // .book-cover img CSS (object-fit: cover + object-position: top).
1760  const croppedCover = b.cover_url
1761    ? _cfg._supabaseRatioCrop(_cfg._cleanCdnUrl(b.cover_url), { width: 400, height: 600 })
1762    : '';
1763  const cover = b.cover_url
1764    ? `<img src="${escHTML(croppedCover)}" alt="" loading="lazy" onerror="this.parentElement.innerHTML='<div class=&quot;book-cover-placeholder&quot;>${initialLetter}</div>'"/>`
1765    : `<div class="book-cover-placeholder">${initialLetter}</div>`;
1766  const genreLabel = b.genre ? b.genre.replace(/-/g, ' ') : '';
1767
1768  const editorsPickBadge = b.is_editors_pick
1769    ? `<div class="book-editors-pick-badge" title="${escHTML(b.editors_pick_note || 'Editor\'s Pick')}">
1770         <svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
1771         Editor's Pick
1772       </div>`
1773    : '';
1774
1775  card.innerHTML = `
1776    <div class="book-cover">
1777      ${cover}
1778      ${editorsPickBadge}
1779      <div class="book-stats">
1780        <span title="Views" data-stat="views">👁 ${_cfg.formatCompact(b.views_count || 0)}</span>
1781        <span title="Likes" data-stat="likes">❤ ${_cfg.formatCompact(b.likes_count || 0)}</span>
1782      </div>
1783    </div>
1784    ${genreLabel ? `<div class="book-card-genre">${escHTML(genreLabel)}</div>` : ''}
1785    <h3 class="book-card-title">${escHTML(b.title || 'Untitled')}</h3>
1786    <p class="book-card-author">by ${escHTML(authorName)}${renderDisplayBadge(b.author || b.profiles)}</p>
1787    ${_renderBookMetaBadges(b)}
1788  `;
1789  return card;
1790}
1791
1792function prettyGenre(slug) {
1793  if (!slug) return '';
1794  if (PRETTY_GENRE[slug]) return PRETTY_GENRE[slug];
1795  return slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
1796}
1797
1798async function getUserBookTaste() {
1799  if (!_cfg.getCurrentUser()) return { reads: [], likes: [] };
1800  const now = Date.now();
1801  if (_userBookTasteCache && (now - _userBookTasteAt) < 60_000) {
1802    return _userBookTasteCache;
1803  }
1804  const [{ data: reads }, { data: likes }] = await Promise.all([
1805    supabase.from('book_reads').select('book_id').eq('user_id', _cfg.getCurrentUser().id).order('last_read_at', { ascending: false }).limit(50),
1806    supabase.from('book_likes').select('book_id').eq('user_id', _cfg.getCurrentUser().id).order('created_at', { ascending: false }).limit(50),
1807  ]);
1808  _userBookTasteCache = { reads: reads || [], likes: likes || [] };
1809  _userBookTasteAt = now;
1810  return _userBookTasteCache;
1811}
1812
1813async function renderBookChips() {
1814  const wrap = document.getElementById('bookGenreChips');
1815  if (!wrap) return;
1816
1817  // Build a candidate set from BOTH platform popularity + user's reading taste
1818  let userTags = {};
1819  let platformGenres = {};
1820
1821  // Platform popularity — count genres + tags across what we've cached.
1822  // Pre-Codex used `typeof allBooks !== 'undefined'` then fell back to
1823  // `window._latestBooksList` — both names are dead (allBooks never
1824  // existed in app.js, _latestBooksList is never written). Codex P2.
1825  //
1826  // The right pool is our own listing cache: `allBooksRaw` is the
1827  // unfiltered server fetch + `allBooksCache` is the filtered/sorted
1828  // view. Prefer Raw because chip seeding wants the full genre/tag mix,
1829  // not a filtered subset. Empty array fallback when no listing has
1830  // been opened yet (first page-load before showBook fires); the chips
1831  // function then falls through to the hardcoded romance/fantasy seed
1832  // list at the bottom of renderBookChips, which is what we want.
1833  const allLoadedBooks = Array.isArray(allBooksRaw) && allBooksRaw.length
1834    ? allBooksRaw
1835    : (Array.isArray(allBooksCache) ? allBooksCache : []);
1836  for (const b of allLoadedBooks) {
1837    if (b.genre) platformGenres[b.genre] = (platformGenres[b.genre] || 0) + 1;
1838    for (const t of (b.tags || [])) {
1839      const slug = String(t).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
1840      if (slug) platformGenres[slug] = (platformGenres[slug] || 0) + 1;
1841    }
1842  }
1843
1844  // User reading taste (only for signed-in users)
1845  if (_cfg.getCurrentUser()) {
1846    try {
1847      const { reads, likes } = await getUserBookTaste();
1848      const seedIds = [...new Set([...reads, ...likes].map(r =>
1848 r.book_id))].slice(0, 30);
1849      if (seedIds.length) {
1850        const { data: seedBooks } = await supabase
1851          .from('books').select('id, genre, tags').in('id', seedIds);
1852        for (const b of (seedBooks || [])) {
1853          if (b.genre) userTags[b.genre] = (userTags[b.genre] || 0) + 2;
1854          for (const t of (b.tags || [])) {
1855            const slug = String(t).toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
1856            if (slug) userTags[slug] = (userTags[slug] || 0) + 1;
1857          }
1858        }
1859      }
1860    } catch {}
1861  }
1862
1863  const userRanked = Object.entries(userTags)
1864    .filter(([t]) => platformGenres[t]) // only show tags that have content
1865    .sort((a, b) => b[1] - a[1])
1866    .map(([t]) => t);
1867
1868  const platformRanked = Object.entries(platformGenres)
1869    .sort((a, b) => b[1] - a[1])
1870    .map(([t]) => t);
1871
1872  // Blend: 70% from interest, 30% platform discovery (or 100% platform for new users)
1873  const limit = 22;
1874  let chips = [];
1875  if (userRanked.length) {
1876    const userQuota = Math.ceil(limit * 0.7);
1877    const seen = new Set(userRanked.slice(0, userQuota));
1878    for (const t of platformRanked) { if (seen.size >= limit) break; seen.add(t); }
1879    chips = [...seen].slice(0, limit);
1880  } else {
1881    chips = platformRanked.slice(0, limit);
1882  }
1883
1884  // Fallback if we still have nothing (e.g. on first page open before any books load)
1885  if (!chips.length) {
1886    chips = ['romance', 'hot-romance', 'dark-romance', 'mafia-boss', 'billionaire', 'werewolf', 'vampire', 'thriller', 'horror', 'fantasy', 'comedy', 'drama'];
1887  }
1888
1889  // Render — "All" chip first, then adaptive list
1890  const activeAll = !bookGenreFilter ? 'active' : '';
1891  let html = `<button class="book-chip ${activeAll}" data-genre="">All</button>`;
1892  html += chips.map(g =>
1893    `<button class="book-chip ${g === bookGenreFilter ? 'active' : ''}" data-genre="${escHTML(g)}">${escHTML(prettyGenre(g))}</button>`
1894  ).join('');
1895  wrap.innerHTML = html;
1896}
1897
1898async function loadBookRecommendations() {
1899  const rail = document.getElementById('bookRecommendRail');
1900  const track = document.getElementById('bookRecommendTrack');
1901  const sub   = document.getElementById('bookRecommendSub');
1902  if (!rail || !track) return;
1903
1904  // Only show for signed-in users
1905  if (!_cfg.getCurrentUser()) { rail.style.display = 'none'; return; }
1906
1907  // Use cache if fresh
1908  if (_bookRecsCache && (Date.now() - _bookRecsTimestamp) < BOOK_RECS_TTL) {
1909    renderBookRecsRail(_bookRecsCache);
1910    return;
1911  }
1912
1913  try {
1914    // Pull user's reading taste from shared cache (avoids duplicate fetch
1915    // since renderBookChips() already pulled this seconds ago)
1916    const { reads, likes } = await getUserBookTaste();
1917    const readIds  = new Set(reads.map(r => r.book_id));
1918    const likedIds = new Set(likes.map(l => l.book_id));
1919    const seedIds  = [...new Set([...readIds, ...likedIds])].slice(0, 30);
1920
1921    // Fetch a candidate pool (most recent public books)
1922    const { data: pool } = await supabase
1923      .from('books')
1924      .select(`
1925        id, title, cover_url, genre, tags, status, content_rating,
1926        is_public, is_hidden, lock_from_chapter, published_at,
1927        views_count, likes_count, chapters_count,
1928        author_id, created_at,
1929        profiles!books_author_id_fkey ( id, username, avatar_url, is_banned, role, roles, badge_role )
1930      `)
1931      .eq('is_public', true)
1932      .eq('is_hidden', false)
1933      .in('status', PUBLIC_READER_BOOK_STATUSES)
1934      .order('created_at', { ascending: false })
1935      .limit(200);
1936
1937    if (!pool || !pool.length) { rail.style.display = 'none'; return; }
1938
1939    // Build interest profile from seed books (tags + authors)
1940    let tagWeights = {};
1941    let authorWeights = {};
1942    if (seedIds.length) {
1943      const { data: seedBooks } = await supabase
1944        .from('books')
1945        .select('id, genre, tags, author_id')
1946        .in('id', seedIds);
1947      for (const sb of (seedBooks || [])) {
1948        for (const t of (sb.tags || [])) tagWeights[t] = (tagWeights[t] || 0) + 1;
1949        if (sb.genre) tagWeights[sb.genre] = (tagWeights[sb.genre] || 0) + 1;
1950        if (sb.author_id) authorWeights[sb.author_id] = (authorWeights[sb.author_id] || 0) + 1;
1951      }
1952    }
1953
1954    const hasInterest = Object.keys(tagWeights).length > 0;
1955
1956    // Score each candidate
1957    const scored = filterReaderEligibleBooks(pool)
1958      .filter(b => !readIds.has(b.id))                  // skip already-read
1959      .map(b => {                                       // (eligibility rule already skips banned authors)
1960        let score = 0;
1961        // Tag overlap with user's interests
1962        for (const t of (b.tags || [])) {
1963          if (tagWeights[t]) score += tagWeights[t] * 12;
1964        }
1965        // Genre match
1966        if (b.genre && tagWeights[b.genre]) score += tagWeights[b.genre] * 10;
1967        // Same-author bonus
1968        if (authorWeights[b.author_id]) score += authorWeights[b.author_id] * 18;
1969        // Engagement boost
1970        score += Math.log10((b.views_count || 0) + 1) * 1.5;
1971        score += Math.log10((b.likes_count || 0) + 1) * 2;
1972        // Recency boost (last 60 days)
1973        const ageDays = (Date.now() - new Date(b.created_at).getTime()) / 86400000;
1974        if (ageDays < 60) score += Math.max(0, 6 - ageDays / 10);
1975        // Random freshness
1976        score += Math.random() * 4;
1977        return { book: b, score };
1978      })
1979      .sort((a, b) => b.score - a.score)
1980      .slice(0, 12)
1981      .map(x => x.book);
1982
1983    _bookRecsCache = scored;
1984    _bookRecsTimestamp = Date.now();
1985
1986    if (sub) sub.textContent = hasInterest ? 'Based on your reading taste' : 'Trending this week';
1987    renderBookRecsRail(scored);
1988  } catch (e) {
1989    console.warn('[recs] failed', e);
1990    rail.style.display = 'none';
1991  }
1992}
1993
1994function renderBookRecsRail(books) {
1995  const rail = document.getElementById('bookRecommendRail');
1996  const track = document.getElementById('bookRecommendTrack');
1997  if (!rail || !track) return;
1998  if (!books || !books.length) { rail.style.display = 'none'; return; }
1999
2000  track.innerHTML = '';
2001  for (const b of books) {
2002    const a = document.createElement('a');
2003    a.href = `#book/${b.id}`;
2004    a.className = 'recommend-card';
2005    a.onclick = (e) => { e.preventDefault(); openBookDetail(b.id, b); };
2006
2007    const initial = (b.title || '?').trim().charAt(0).toUpperCase();
2008    // Pre-crop to 2:3 server-side (Supabase) so the rec rail covers
2009    // arrive at the right ratio instead of getting CSS-forced.
2010    const croppedCover = b.cover_url
2011      ? _cfg._supabaseRatioCrop(_cfg._cleanCdnUrl(b.cover_url), { width: 400, height: 600 })
2012      : '';
2013    a.innerHTML = `
2014      <div class="recommend-card-cover">
2015        ${b.cover_url
2016          ? `<img src="${escHTML(croppedCover)}" alt="" loading="lazy"/>`
2017          : `<div class="recommend-card-cover-empty">${escHTML(initial)}</div>`}
2018      </div>
2019      <div class="recommend-card-title">${escHTML(b.title || 'Untitled')}</div>
2020      <div class="recommend-card-author">${escHTML(b.profiles?.username || 'Unknown')}</div>
2021      <div class="recommend-card-meta">${(b.views_count || 0).toLocaleString()} reads · ${(b.likes_count || 0).toLocaleString()} ♥</div>
2022    `;
2023    track.appendChild(a);
2024  }
2025  rail.style.display = 'block';
2026}
2027
2028
2029
2030// ════════════════════════════════════════════════════════════════════════
2031// Stage 8B — Book detail page + chapter reader (appended by extract-stage8b.js)
2032// ════════════════════════════════════════════════════════════════════════
2033
2034// ─── Module state (8B) ─────────────────────────────────────────────
2035let currentBookDetail = null;       // { book, chapters }
2036let currentChapterIndex = 0;
2037let readerFontSize = parseFloat(localStorage.getItem('selebox_reader_font') || '1.05');
2038// BX-6A reader comfort settings (client-only; persisted in localStorage; reader-scoped).
2039// Theme/line are applied as data-attributes on #chapterReaderPage and styled by scoped CSS;
2040// font size keeps its own control (A−/A+ → selebox_reader_font) untouched.
2041const _READER_THEMES = ['light', 'sepia', 'dark'];
2042const _READER_LINES = ['compact', 'normal', 'relaxed'];
2043// BX-6B advanced reader settings (client-only; localStorage; reader-scoped via data-attrs).
2044const _READER_FAMILIES = ['sans'];
2045const _READER_WIDTHS = ['narrow', 'normal', 'wide'];
2046const _READER_PARAGRAPHS = ['compact', 'normal', 'spacious'];
2047// (Old genre-chip + sort-select handlers removed alongside their DOM. The v2
2048// books page expresses the same intent through tabs and curated sections —
2049// "trending", "recent", "most-liked" etc. live in _SEE_ALL_MAP for the See-All
2050// list view.)
2051
2052// ── Book detail page ──
2053// Stale-fetch guard so rapid taps (open A → tap B before A loads) don't
2054// render whichever happens to resolve last. Each open gets a unique token,
2055// including repeated opens of the same book.
2056let _openBookToken = null;
2057let _readerRequestGeneration = 0;
2058let _disposePublishedChapter = null;
2059
2060function _readerIdentity() {
2061  const profile = _cfg.getCurrentProfile?.();
2062  return JSON.stringify([_cfg.getCurrentUser?.()?.id || '', profile?.id || '', profile?.role || '']);
2063}
2064
2065// Synchronous auth teardown: never flush the old account's progress through
2066// the next account's session. Cancel requests and remove already-rendered text.
2067export function resetBookReaderSession() {
2068  _disposePublishedChapter?.(); _disposePublishedChapter = null;
2069  _readerRequestGeneration += 1;
2070  _openBookToken = null;
2071  currentBookDetail = null;
2072  currentChapterIndex = 0;
2073  if (_scrollSaveTimer) clearTimeout(_scrollSaveTimer);
2074  _scrollSaveTimer = null;
2075  _scrollSaveBookId = null;
2076  _scrollSaveChapterId = null;
2077  _pendingResume = null;
2078  _readChapterOpenTs = 0;
2079  _readChapterOpenId = null;
2080  _readChapterOpenBookId = null;
2081  _readMaxScrollPct = 0;
2082  _watermarkLabelCache = null;
2083  _resetReaderComments();
2084  for (const id of ['readerContent', 'readerBookTitle', 'readerChapterTitle', 'readerProgress', 'bookDetailContent', 'readerCommentsList']) {
2085    const element = document.getElementById(id);
2086    if (element) element.textContent = '';
2087  }
2088  for (const id of ['readerCommentsModal', 'readerChaptersModal']) {
2089    document.getElementById(id)?.classList.remove('open');
2090  }
2091}
2092// ── Reader watermark (deterrent: leaked screenshots reveal the source) ──
2093let _watermarkLabelCache = null;
2094let _readMaxScrollPct = 0;
2095// ── Anti-fraud telemetry state for the book reader ───────────────────────
2096// Tracks the currently-open chapter so the close event can include
2097// real dwell_ms + max scroll_pct. _readChapterOpenTs is 0 when no
2098// chapter is open. flushReadClose() drains + resets the state and
2099// emits the matching close record_read_event. Called from anywhere
2100// that exits a chapter (next/prev nav, back button, route change).
2101let _readChapterOpenTs = 0;
2102let _readChapterOpenId = null;
2103let _readChapterOpenBookId = null;
2104
2105// ─── Extracted functions (8B) ──────────────────────────────────────
2106
2107async function openBookDetail(bookId, opts = {}) {
2108  // Stage 8B Codex P1 — accept an `opts.chapter` chapter-number hint
2109  // for deep-link auto-open. When the inbound URL is /books/<id>/chapter/<n>
2110  // the boot router (or popstate handler) passes the chapter number
2111  // here; after the chapters list lands we find the matching index and
2112  // call openChapterReader directly. Pre-fix this lived as
2113  // _pendingChapterFromUrl in app.js but was set-only — nothing read
2114  // it, so deep-linked chapters loaded only the detail page.
2115  const requestedChapter = { chapterId: opts.chapterId, chapter: opts.chapter };
2116
2117  _cfg.hideAllMainPages();
2118  _readerRequestGeneration += 1;
2119  bookDetailPage.style.display = 'block';
2120
2121  // Path-based, shareable, deep-link-friendly URL. Use replaceState when
2122  // the URL bar already shows the same book (e.g. boot-time inbound) so
2123  // we don't add a duplicate history entry; pushState otherwise. Either
2124  // way the result is `/books/<id>`, which mobile's Universal Links /
2125  // App Links pick up correctly. The legacy hash form (#book/<id>) only
2126  // appears as an inbound rewrite from old links — once we hit
2127  // openBookDetail we always upgrade to the canonical path form.
2128  const targetPath = `/books/${bookId}`;
2129  const samePath = (location.pathname || '') === targetPath;
2130  if (samePath) {
2131    history.replaceState(null, '', targetPath);
2132  } else {
2133    history.pushState(null, '', targetPath);
2134  }
2135
2136  const content = document.getElementById('bookDetailContent');
2137  content.innerHTML = '<div class="loading">Loading book...</div>';
2138
2139  // Only the LATEST openBookDetail call gets to render. Earlier ones bail
2140  // when their token no longer matches — fixes the "open A, tap B quickly,
2141  // see A's cover with B's chapters" race.
2142  const token = Symbol(bookId);
2143  const identity = _readerIdentity();
2144  _openBookToken = token;
2145  const isCurrent = () => _openBookToken === token && _readerIdentity() === identity
2146    && (!opts.notificationRoute || opts.notificationRoute.isCurrent());
2147
2148  // All books are now Supabase. Bare UUID or "sb_<uuid>" — strip the prefix if present.
2149  const realId = bookId.startsWith('sb_') ? bookId.slice(3) : bookId;
2150
2151  // Detect ID shape — Supabase UUIDs are 36 chars with dashes
2152  // (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx); Appwrite hex IDs are 20 hex
2153  // chars with no dashes. Mobile users share Appwrite-shaped IDs via
2154  // WhatsApp/SMS because the mobile app still has books on Appwrite.
2155  // Web migrated books to Supabase but kept legacy_appwrite_id, so we
2156  // query the right column for whichever shape we see.
2157  const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(realId);
2158  const bookFilterColumn = isUuid ? 'id' : 'legacy_appwrite_id';
2159
2160  let supBook, supChapters;
2161  try {
2162    const bookRes = await supabase.from('books')
2163      .select(`
2164        id, title, description, cover_url, genre, tags,
2165        views_count, likes_count, ratings_avg, ratings_count, chapters_count, word_count, status, content_rating,
2166        is_public, is_hidden, is_ai_generated,
2167        published_at, created_at, lock_from_chapter, locked_at,
2168        author_id, profiles!books_author_id_fkey ( id, username, avatar_url, is_banned, role, roles, badge_role )
2169      `)
2170      .eq(bookFilterColumn, realId)
2171      // Public-reader eligibility is enforced server-side too (public + not
2172      // hidden + reader-visible status): a non-eligible id returns 0 rows, so
2173      // .single() errors and we surface a clean "Book not found" instead of
2174      // leaking a draft/paused/private book to anyone who guesses the URL.
2175      .eq('is_public', true).eq('is_hidden', false).in('status', PUBLIC_READER_BOOK_STATUSES)
2176      .single();
2177    if (!isCurrent()) return;
2178    if (bookRes.error || !bookRes.data) throw new Error(bookRes.error?.message || 'Book not found');
2179    supBook = bookRes.data;
2180    // Belt-and-suspenders: the query above cannot filter the joined author's
2181    // ban flag, so re-check the full shared rule (covers banned-author books).
2182    if (!isReaderEligibleBook(supBook)) throw new Error('Book not found');
2183
2184    // Chapters are always keyed by the book's Supabase UUID, regardless
2185    // of which shape we used to find the book.
2186    supBook = (await hydrateCatalogBooks(supabase,[supBook]))[0];
2187    supChapters = await readAllLiveParts(supabase,supBook.id,{isCurrent});
2188  } catch (err) {
2189    if (!isCurrent()) return;
2190    console.error('openBookDetail failed:', err);
2191    const friendly = /timeout|canceling statement/i.test(err.message || '')
2192      ? 'This book is taking too long to load. Tap to retry.'
2193      : /not found/i.test(err.message || '')
2194      ? 'This book isn\'t available — it may have been unpublished.'
2195      : 'Couldn\'t load this book. Tap to retry.';
2196    content.innerHTML = `<div class="loading" id="bookRetry" style="cursor:pointer">${escHTML(friendly)}</div>`;
2197    document.getElementById('bookRetry')?.addEventListener('click', () => openBookDetail(bookId, opts));
2198    return;
2199  }
2200
2201  // BX-UNLOCK-1: refresh the entitlement cache (_userUnlocks) from public.unlocks
2202  // so the TOC lock badges reflect unlocks made on another platform/session.
2203  // Read-only + RLS-scoped; no-op when logged out. Token re-checked after the
2204  // await so a stale (user-tapped-another-book) render is still suppressed.
2205  await refreshUnlocks();
2206
2207  if (!isCurrent()) return;
2208
2209  currentBookDetail = { book: supBook, chapters: supChapters || [] };
2210  renderBookDetail();
2211
2212  // Battle samples resolve the server-selected chapter ID. Shared links and
2213  // Continue Reading retain their chapter-number destination. The existing
2214  // reader applies the same entitlement checks to either entry point.
2215  const requestedChapterIndex = resolveChapterTarget(supChapters, requestedChapter);
2216  if (requestedChapterIndex >= 0) {
2217    const open = () => openChapterReader(requestedChapterIndex);
2218    return opts.notificationRoute ? opts.notificationRoute.transition(open) : open();
2219  }
2220}
2221
2222// ── BX-4 author follow on book detail (mobile parity) ────────────────────────
2223// Reuses the SAME secure direct-RLS `follows` write the profile page uses
2224// (js/profile.js toggleFollow is module-private, so the small logic is mirrored
2225// here). follows RLS: INSERT/DELETE are bound to current_profile_id() =
2226// follower_id (anon blocked, no follower_id spoofing); PK (follower_id,
2227// following_id) dedups; a DB CHECK forbids self-follow. The button is rendered
2228// ONLY for signed-in non-author viewers, so a logged-out viewer never queries
2229// or writes follows.
2230function _setFollowButtonState(btn, following) {
2231  if (!btn) return;
2232  btn.dataset.following = following ? '1' : '0';
2233  const label = btn.querySelector('.book-action-label');
2234  if (label) label.textContent = following ? 'Following' : 'Follow';
2235}
2236
2237async function _isFollowingAuthor(authorId) {
2238  const me = _cfg.getCurrentUser();
2239  if (!me?.id || !authorId) return false;
2240  try {
2241    const { data } = await supabase.from('follows')
2242      .select('follower_id')
2243      .eq('follower_id', me.id).eq('following_id', authorId).maybeSingle();
2244    return !!data;
2245  } catch { return false; }
2246}
2247
2248async function _toggleBookAuthorFollow(authorId, btn) {
2249  const me = _cfg.getCurrentUser();
2250  if (!me?.id || !authorId || !btn) return;
2251  if (btn.dataset.busy === '1') return;          // ignore double-taps mid-write
2252  btn.dataset.busy = '1';
2253  const wasFollowing = btn.dataset.following === '1';
2254  _setFollowButtonState(btn, !wasFollowing);     // optimistic flip
2255  try {
2256    const { error } = wasFollowing
2257      ? await supabase.from('follows').delete().eq('follower_id', me.id).eq('following_id', authorId)
2258      : await supabase.from('follows').insert({ follower_id: me.id, following_id: authorId });
2259    if (error) throw error;
2260  } catch (e) {
2261    _setFollowButtonState(btn, wasFollowing);     // revert on failure
2262    toast('Could not update follow', 'error');
2263  } finally {
2264    btn.dataset.busy = '0';
2265  }
2266}
2267
2268// ── BX-5 reader chapter TOC / jump-to-chapter (read-only; mobile parity) ─────
2269// Lists currentBookDetail.chapters (metadata only — the book-detail SELECT omits
2270// chapters.content) in a modal opened from the reader bar. Tapping a row routes
2271// through the EXISTING gated reader (openChapterReader → get_chapter_content_if_
2272// entitled), so locked/unentitled chapters still hit the paywall and NO chapter
2273// content is fetched here. No new DB query; reuses in-memory state + styled
2274// classes (.modal-overlay/.chapter-row). BX-2B handles scroll reset on jump.
2275// ── BX-8A: web premium-chapter "mobile app only" gate (anti-theft; frontend deterrence) ──────
2276// TRUE = this chapter must NOT be read on the web by this viewer. Premium = chapter.is_locked OR
2277// at/after the book's paywall start (lock_from_chapter, INCLUSIVE). Deliberately NOT unlock-aware:
2278// a normal reader is gated on web even if they already unlocked (their access stays valid in the
2279// mobile app). unlock_cost is NOT a trigger (it is price metadata that can persist on free/promo
2280// chapters). Author / admin / moderator bypass via _cfg.getCurrentProfile() (frontend gate; the
2281// content RPC stays the server-side authority — BX-8B is the real enforcement). Profile is finalized
2282// before any reader path is reachable (sign-in-gated app; routing runs at the end of onSignedIn),
2283// so there is no profile-init race here (PM Option B — proceed without an app.js flag).
2284function _isWebMobileOnlyChapter(chapter, book) {
2285  if (!chapter || !book) return false;
2286  const profile = _cfg.getCurrentProfile?.() || null;
2287  if (profile?.id && book.author_id && profile.id === book.author_id) return false;   // author preview
2288  if (profile?.role === 'admin' || profile?.role === 'moderator') return false;        // admin/mod QA
2289  const lockFrom = Number(book.lock_from_chapter || 0);
2290  const chapterNo = Number(chapter.chapter_number || 0);
2291  return chapter.is_locked === true || (lockFrom > 0 && chapterNo >= lockFrom);
2292}
2293
2294function _isChapterLockedTOC(book, c) {
2295  // Mirror of renderBookDetail's isChapterLockedForReader (display-only badge).
2296  const lockFrom = book?.lock_from_chapter || null;
2297  if (!lockFrom && !c.is_locked) return false;
2298  const isAtOrAfterLockPoint = lockFrom != null && c.chapter_number >= lockFrom;
2299  if (!isAtOrAfterLockPoint && !c.is_locked) return false;
2300  const realId = String(c.id).startsWith('sb_') ? String(c.id).slice(3) : c.id;
2301  return !_cfg.isUnlocked('chapter', realId);
2302}
2303
2304function _renderReaderChaptersList() {
2305  const listEl = document.getElementById('readerChaptersList');
2306  if (!listEl || !currentBookDetail) return;
2307  const { book, chapters } = currentBookDetail;
2308  listEl.innerHTML = (chapters || []).map((c, i) => {
2309    const locked = _isChapterLockedTOC(book, c);
2310    const mobileOnly = _isWebMobileOnlyChapter(c, book);   // BX-8A: premium → web mobile-only badge
2311    const isCurrent = i === currentChapterIndex;
2312    const lockBadge = locked
2313      ? `<span class="chapter-row-lock" title="Locked" aria-label="Locked chapter">
2314           <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
2315         </span>`
2316      : '';
2317    // Current-chapter highlight: aria-current + a left accent (CSS var with a
2318    // safe brand-purple fallback) + a "Reading" meta label (not icon-only).
2319    const meta = isCurrent
2320      ? '<span class="chapter-row-meta">Reading</span>'
2321      : (locked ? '<span class="chapter-row-meta">Locked</span>' : '');
2322    return `
2323      <div class="chapter-row${locked ? ' is-locked' : ''}" role="button" tabindex="0" data-index="${i}"
2324        ${isCurrent ? 'aria-current="true" style="border-left:3px solid var(--accent-primary)"' : ''}>
2325        <span class="chapter-row-num">#${c.chapter_number}</span>
2326        ${lockBadge}
2327        <span class="chapter-row-title"${isCurrent ? ' style="font-weight:700"' : ''}>${escHTML(c.title || `#${c.chapter_number}`)}</span>
2328        ${mobileOnly ? `<span class="chapter-row-meta" style="color:var(--accent,#fbbf24)" title="Premium — read in the Selebox mobile app">📱 Mobile app only</span>` : ''}
2329        ${meta}
2330      </div>
2331    `;
2332  }).join('');
2333  const jump = (row) => {
2334    const idx = parseInt(row.dataset.index, 10);
2335    _closeReaderChapters();
2336    if (Number.isFinite(idx)) openChapterReader(idx);   // existing entitlement-gated path
2337  };
2338  listEl.querySelectorAll('.chapter-row').forEach(row => {
2339    row.addEventListener('click', () => jump(row));
2340    row.addEventListener('keydown', (e) => {
2341      if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); jump(row); }
2342    });
2343  });
2344}
2345
2346function _openReaderChapters() {
2347  if (!currentBookDetail) return;
2348  _renderReaderChaptersList();
2349  document.getElementById('readerChaptersModal')?.classList.add('open');
2350}
2351
2352function _closeReaderChapters() {
2353  document.getElementById('readerChaptersModal')?.classList.remove('open');
2354}
2355
2356// ── BX-6A reader settings: theme + line-spacing (presentation only; localStorage) ───────────
2357// Applies data-reader-theme / data-reader-line to #chapterReaderPage; scoped CSS in style.css
2358// restyles the reader SURFACE only (never the global site theme). This touches nothing in the
2359// content path — no fetch, no entitlement, no sanitization; it only re-skins the already-rendered
2360// reader container. Invalid/missing values fall back safely (theme → follow global; line → normal).
2361function _getReaderTheme() {
2362  const v = localStorage.getItem('selebox_reader_theme');
2363  return _READER_THEMES.includes(v) ? v : null;   // null/invalid → follow global site theme
2364}
2365function _getReaderLine() {
2366  const v = localStorage.getItem('selebox_reader_line');
2367  return _READER_LINES.includes(v) ? v : 'normal'; // invalid/missing → normal (= existing 1.85)
2368}
2369// Inter is the shared website font; retain width and paragraph preferences.
2370function _getReaderFamily() {
2371  const v = localStorage.getItem('selebox_reader_family');
2372  return _READER_FAMILIES.includes(v) ? v : 'sans'; // Inter is the website-wide reading font.
2373}
2374function _getReaderWidth() {
2375  const v = localStorage.getItem('selebox_reader_width');
2376  return _READER_WIDTHS.includes(v) ? v : 'normal';       // → normal (= 720px)
2377}
2378function _getReaderParagraph() {
2379  const v = localStorage.getItem('selebox_reader_paragraph');
2380  return _READER_PARAGRAPHS.includes(v) ? v : 'normal';   // → normal (= 1.2em)
2381}
2382function _applyReaderSettings() {
2383  if (!chapterReaderPage) return;
2384  const theme = _getReaderTheme();
2385  if (theme) chapterReaderPage.setAttribute('data-reader-theme', theme);
2386  else chapterReaderPage.removeAttribute('data-reader-theme');   // unset → global site theme
2387  chapterReaderPage.setAttribute('data-reader-line', _getReaderLine());
2388  chapterReaderPage.setAttribute('data-reader-family', _getReaderFamily());       // BX-6B
2389  chapterReaderPage.setAttribute('data-reader-width', _getReaderWidth());         // BX-6B
2390  chapterReaderPage.setAttribute('data-reader-paragraph', _getReaderParagraph()); // BX-6B
2391}
2392function _setReaderTheme(theme) {
2393  if (!_READER_THEMES.includes(theme)) return;
2394  localStorage.setItem('selebox_reader_theme', theme);
2395  _applyReaderSettings();
2396  _syncReaderSettingsUI();
2397}
2398function _setReaderLine(line) {
2399  if (!_READER_LINES.includes(line)) return;
2400  localStorage.setItem('selebox_reader_line', line);
2401  _applyReaderSettings();
2402  _syncReaderSettingsUI();
2403}
2404function _setReaderFamily(family) {
2405  if (!_READER_FAMILIES.includes(family)) return;
2406  localStorage.setItem('selebox_reader_family', family);
2407  _applyReaderSettings();
2408  _syncReaderSettingsUI();
2409}
2410function _setReaderWidth(width) {
2411  if (!_READER_WIDTHS.includes(width)) return;
2412  localStorage.setItem('selebox_reader_width', width);
2413  _applyReaderSettings();
2414  _syncReaderSettingsUI();
2415}
2416function _setReaderParagraph(par) {
2417  if (!_READER_PARAGRAPHS.includes(par)) return;
2418  localStorage.setItem('selebox_reader_paragraph', par);
2419  _applyReaderSettings();
2420  _syncReaderSettingsUI();
2421}
2422function _syncReaderSettingsUI() {
2423  // Reflect the active selection on the toggle buttons. When no reader theme is saved, the
2424  // reader follows the global theme — so highlight whichever option matches that effective state.
2425  const effectiveTheme = _getReaderTheme() || (document.body.classList.contains('light') ? 'light' : 'dark');
2426  const effectiveLine = _getReaderLine();
2427  document.querySelectorAll('#readerSettingsModal [data-set-theme]').forEach((b) => {
2428    b.setAttribute('aria-pressed', b.dataset.setTheme === effectiveTheme ? 'true' : 'false');
2429  });
2430  document.querySelectorAll('#readerSettingsModal [data-set-line]').forEach((b) => {
2431    b.setAttribute('aria-pressed', b.dataset.setLine === effectiveLine ? 'true' : 'false');
2432  });
2433  // BX-6B: font family / width / paragraph spacing toggles.
2434  const effectiveFamily = _getReaderFamily();
2435  const effectiveWidth = _getReaderWidth();
2436  const effectivePar = _getReaderParagraph();
2437  document.querySelectorAll('#readerSettingsModal [data-set-family]').forEach((b) => {
2438    b.setAttribute('aria-pressed', b.dataset.setFamily === effectiveFamily ? 'true' : 'false');
2439  });
2440  document.querySelectorAll('#readerSettingsModal [data-set-width]').forEach((b) => {
2441    b.setAttribute('aria-pressed', b.dataset.setWidth === effectiveWidth ? 'true' : 'false');
2442  });
2443  document.querySelectorAll('#readerSettingsModal [data-set-paragraph]').forEach((b) => {
2444    b.setAttribute('aria-pressed', b.dataset.setParagraph === effectivePar ? 'true' : 'false');
2445  });
2446}
2447function _openReaderSettings() {
2448  _syncReaderSettingsUI();
2449  document.getElementById('readerSettingsModal')?.classList.add('open');
2450}
2451function _closeReaderSettings() {
2452  document.getElementById('readerSettingsModal')?.classList.remove('open');
2453}
2454
2455// Comments are available only for the currently entitled chapter.
2456let _readerCommentsChapterId = null, _readerMentionBinding = null;
2457let _commentReply = null, _commentPending = false, _commentAttempt = null, _commentGeneration = 0;
2458function _syncCommentComposer() {
2459  const form = document.getElementById('readerCommentForm');
2460  if (!form) return;
2461  const signedIn = !!_cfg.getCurrentUser()?.id;
2462  form.hidden = !signedIn;
2463  document.getElementById('readerCommentSignIn').hidden = signedIn;
2464  const input = document.getElementById('readerCommentInput');
2465  input.disabled = _commentPending;
2466  const send = document.getElementById('readerCommentSend');
2467  send.disabled = _commentPending || !input.value.trim();
2468  send.textContent = _commentPending ? 'Posting…' : _commentReply ? 'Post reply' : 'Post comment';
2469  document.getElementById('readerCommentCount').textContent = `${input.value.length.toLocaleStr
2469ing()} / 2,000`;
2470  const context = document.getElementById('readerCommentReply');
2471  context.hidden = !_commentReply;
2472  document.getElementById('readerCommentReplyName').textContent = _commentReply ? `Replying to ${_commentReply.name}` : '';
2473}
2474async function _sendChapterComment(event) {
2475  event.preventDefault();
2476  if (_commentPending || !_readerCommentsChapterId || !_cfg.getCurrentUser()?.id) return;
2477  const input = document.getElementById('readerCommentInput');
2478  const status = document.getElementById('readerCommentStatus');
2479  let body;
2480  try { body = commentMentionBody(input).trim(); } catch (error) { status.textContent = error.message; return; }
2481  if (!body) return;
2482  const chapterId = _readerCommentsChapterId, identity = _readerIdentity(), generation = _readerRequestGeneration;
2483  const profileId = _cfg.getCurrentProfile?.()?.id;
2484  if (!profileId) { status.textContent = 'Your profile is still loading. Please try again.'; return; }
2485  const parentId = _commentReply?.id || null;
2486  const attemptKey = JSON.stringify([chapterId, identity, body, parentId]);
2487  if (_commentAttempt?.key !== attemptKey) _commentAttempt = { key: attemptKey, id: crypto.randomUUID() };
2488  _commentPending = true; status.textContent = ''; _syncCommentComposer();
2489  const current = () => chapterId === _readerCommentsChapterId && identity === _readerIdentity() && generation === _readerRequestGeneration;
2490  try {
2491    await createChapterCommentWriter({ client: supabase }).send({ id: _commentAttempt.id, chapterId, profileId, body, parentId });
2492    if (!current()) return;
2493    input.value = ''; _commentAttempt = null; _commentReply = null;
2494    status.textContent = parentId ? 'Reply posted.' : 'Comment posted.';
2495    await _loadChapterComments(chapterId);
2496    // Reopen the replied-to thread when it is on the current page.
2497    if (current() && parentId) {
2498      const toggle = [...document.querySelectorAll('#readerCommentsList [data-reply-toggle]')].find(b => b.dataset.parent === parentId);
2499      if (toggle) await _loadCommentReplies(parentId, toggle, toggle.parentElement.querySelector('.comment-replies'));
2500    }
2501  } catch (error) {
2502    if (current()) status.textContent = 'Could not post. Your draft is safe. ' + (error.message || 'Please try again.');
2503  } finally { if (current()) { _commentPending = false; _syncCommentComposer(); } }
2504}
2505function _resetReaderComments() {
2506  _readerMentionBinding?.destroy(); _readerMentionBinding = null;
2507  _readerCommentsChapterId = null;
2508  _commentGeneration++; _commentReply = null; _commentAttempt = null; _commentPending = false;
2509  const input = document.getElementById('readerCommentInput');
2510  if (input) input.value = '';
2511  const status = document.getElementById('readerCommentStatus');
2512  if (status) status.textContent = '';
2513  const btn = document.getElementById('btnReaderComments');
2514  if (btn) btn.style.display = 'none';
2515}
2516function _revealReaderComments(chapterId) {
2517  _readerMentionBinding?.destroy();
2518  _readerCommentsChapterId = chapterId || null;
2519  _readerMentionBinding = chapterId ? bindCommentMentions(document.getElementById('readerCommentInput'), { client: supabase, getActor: () => _cfg.getCurrentProfile?.()?.id || '', audience: {kind:'chapter', chapterId}, onChange: _syncCommentComposer }) : null;
2520  const btn = document.getElementById('btnReaderComments');
2521  if (btn && _readerCommentsChapterId) btn.style.display = '';
2522}
2523function _renderChapterCommentItem(row, isReply = false) {
2524  const attr = value => escHTML(String(value || '')).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
2525  const p = row.profiles || null;
2526  const name = (p && (p.display_name || p.username)) ? String(p.display_name || p.username) : 'Unknown reader';
2527  const avatarUrl = (p && p.avatar_url) ? String(p.avatar_url) : '';
2528  const avatar = avatarUrl ? `<img src="${attr(avatarUrl)}" alt=""/>` : escHTML(initials(name));
2529  const when = _continueTimeAgo(row.created_at);
2530  const text = String(row.content || '').trim();
2531  const likes = Math.max(0, Number(row.likes_count) || 0);
2532  const replies = Math.max(0, Number(row.replies_count) || 0);
2533  const likesMeta = likes > 0
2534    ? `<div style="margin-top:.3rem;color:var(--text2);font-size:.78rem">
2534♥ ${escHTML(likes.toLocaleString())}</div>`
2535    : '';
2536  // BX-7B-2: a top-level comment with replies gets a lazy "View N replies" toggle + a nested,
2537  // initially-hidden container. Replies (isReply) are ONE-LEVEL — they never render a nested
2538  // toggle. row.id is the parent key; the reply load is constrained to the entitled chapter +
2539  // this parent_id in _loadCommentReplies (delegated click handler wired in wireBookReader).
2540  const cid = attr(row.id);
2541  const replyBlock = (!isReply && replies > 0)
2542    ? `<button type="button" class="reader-tool comment-reply-toggle" data-reply-toggle data-parent="${cid}" data-loaded="0" aria-expanded="false" style="margin-top:.45rem;font-size:.74rem;padding:.28rem .6rem">View ${escHTML(replies.toLocaleString())} repl${replies === 1 ? 'y' : 'ies'}</button>
2543        <div class="comment-replies" data-replies-for="${cid}" style="display:none;margin-top:.25rem;padding-left:.85rem;border-left:2px solid var(--border,rgba(255,255,255,0.08))"></div>`
2544    : '';
2545  return `
2546    <div class="chapter-comment-item" data-comment-id="${cid}" style="display:flex;gap:.65rem;padding:.7rem 0;border-top:1px solid var(--border,rgba(255,255,255,0.08))">
2547      <div class="avatar sm">${avatar}</div>
2548      <div style="flex:1;min-width:0">
2549        <div style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
2550          <span style="font-weight:700">${escHTML(name)}</span>
2551          ${when ? `<span style="color:var(--text2);font-size:.78rem">${escHTML(when)}</span>` : ''}
2552        </div>
2553        <div style="margin-top:.3rem;white-space:pre-wrap;overflow-wrap:anywhere">${renderCommentMentions(text)}</div>
2554        ${likesMeta}
2555        ${!isReply ? `<button type="button" class="reader-tool comment-reply-action" data-comment-reply="${cid}" data-comment-author="${attr(name)}">Reply</button>` : ''}
2556        ${replyBlock}
2557      </div>
2558    </div>`;
2559}
2560async function _loadChapterComments(chapterId, offset = 0) {
2561  const list = document.getElementById('readerCommentsList');
2562  if (!list || !chapterId) return;
2563  const identity = _readerIdentity(), generation = ++_commentGeneration;
2564  if (!offset) list.innerHTML = '<div class="loading">Loading comments…</div>';
2565  try {
2566    const { data, error } = await supabase
2567      .from('chapter_comments')
2568      .select('id, content, created_at, likes_count, replies_count, profiles!chapter_comments_user_id_fkey ( id, username, display_name, avatar_url, is_banned, role, roles, badge_role )')
2569      .eq('chapter_id', chapterId)
2570      .is('parent_id', null)
2571      .order('created_at', { ascending: false })
2572      .range(offset, offset + 19);
2573    if (error) throw error;
2574    // Ignore a slow response if the user navigated to a different chapter meanwhile.
2575    if (_readerCommentsChapterId !== chapterId || identity !== _readerIdentity() || generation !== _commentGeneration) return;
2576    const rows = (data || [])
2577      .filter(r => !(r.profiles && r.profiles.is_banned === true))   // hide banned reviewers
2578      .filter(r => String(r.content || '').trim().length > 0);       // drop empty/whitespace-only
2579    list.querySelector('[data-comments-more]')?.remove();
2580    const markup = rows.map(r => _renderChapterCommentItem(r)).join('');
2581    if (offset) list.insertAdjacentHTML('beforeend', markup);
2582    else list.innerHTML = markup || '<div class="book-section-empty">No comments yet. Start the conversation.</div>';
2583    if ((data || []).length === 20) {
2584      const more = document.createElement('button'); more.type = 'button'; more.className = 'reader-tool'; more.dataset.commentsMore = '1'; more.textContent = 'Load more comments';
2585      more.onclick = () => { more.disabled = true; _loadChapterComments(chapterId, offset + 20); }; list.append(more);
2586    }
2587    return true;
2588  } catch (e) {
2589    if (_readerCommentsChapterId !== chapterId || identity !== _readerIdentity() || generation !== _commentGeneration) return;
2590    if (offset) { const more = list.querySelector('[data-comments-more]'); if (more) { more.disabled = false; more.textContent = 'Retry loading more'; } }
2591    else list.innerHTML = '<div class="book-section-empty">Couldn’t load comments. <button type="button" class="reader-tool" data-comments-retry>Try again</button></div>';
2592    return false;
2593  }
2594}
2595function _openReaderComments() {
2596  if (!_readerCommentsChapterId) return;   // only reachable when an entitled chapter revealed the button
2597  document.getElementById('readerCommentsModal')?.classList.add('open');
2598  _syncCommentComposer();
2599  document.getElementById('readerCommentsClose')?.focus();
2600  _loadChapterComments(_readerCommentsChapterId);
2601}
2602// Notification focus stays inside the chapter whose entitlement opened this reader.
2603export async function focusBookComment(bookId, { commentId = '', chapterId = '' } = {}) {
2604  const entitled = _readerCommentsChapterId, identity = _readerIdentity(), request = _readerRequestGeneration;
2605  const current = () => entitled && entitled === _readerCommentsChapterId && identity === _readerIdentity() && request === _readerRequestGeneration && currentBookDetail?.book?.id === bookId;
2606  if (!current() || chapterId && chapterId !== entitled) return false;
2607  document.getElementById('readerCommentsModal')?.classList.add('open');
2608  _syncCommentComposer();
2609  await _loadChapterComments(entitled);
2610  if (!current()) return false;
2611  if (!commentId) return true;
2612  const {data: target, error} = await supabase.from('chapter_comments').select('id,parent_id').eq('chapter_id', entitled).eq('id', commentId).maybeSingle();
2613  if (error || !target || !current()) return false;
2614  const list = document.getElementById('readerCommentsList');
2615  const find = id => [...list.querySelectorAll('[data-comment-id]')].find(el => el.dataset.commentId === id);
2616  const rootId = target.parent_id || target.id;
2617  let offset = 20;
2618  while (current() && !find(rootId) && list.querySelector('[data-comments-more]')) {
2619    if (!await _loadChapterComments(entitled, offset)) return false; offset += 20;
2620  }
2621  if (!current() || !find(rootId)) return false;
2622  if (target.parent_id) {
2623    const root = find(rootId), toggle = root.querySelector('[data-reply-toggle]'), container = root.querySelector('.comment-replies');
2624    if (!container) return false;
2625    await _loadCommentReplies(rootId, toggle, container);
2626    let replyOffset = 20;
2627    while (current() && !find(commentId) && container.querySelector('[data-replies-more]')) {
2628      const before = container.querySelectorAll('[data-comment-id]').length;
2629      await _loadCommentReplies(rootId, toggle, container, replyOffset); replyOffset += 20;
2630      if (container.querySelectorAll('[data-comment-id]').length === before) return false;
2631    }
2632  }
2633  if (!current()) return false;
2634  const row = find(commentId); if (!row) return false;
2635  row.classList.add('comment-notification-focus'); row.setAttribute('tabindex','-1'); row.focus({preventScroll:true}); row.scrollIntoView?.({block:'center',behavior:'auto'});
2636  return true;
2637}
2638function _closeReaderComments() {
2639  document.getElementById('readerCommentsModal')?.classList.remove('open');
2640  document.getElementById('btnReaderComments')?.focus();
2641}
2642// BX-7B-2: lazy-load ONE-LEVEL replies for a top-level comment. SAFETY — only runs when an
2643// entitled chapter modal is active (_readerCommentsChapterId set) and the query is pinned to
2644// that chapter AND the parent_id, so a spoofed cross-chapter parent_id returns nothing (live
2645// integrity: every reply shares its parent's chapter_id). Read-only; reuses the escaped item
2646// renderer with isReply=true (no nested toggle). First click fetches; later clicks toggle
2647// show/hide without refetching. Non-fatal on error.
2648async function _loadCommentReplies(parentId, btn, container, offset = 0) {
2649  if (!parentId || !container) return;
2650  const chapterId = _readerCommentsChapterId, identity = _readerIdentity();
2651  if (!chapterId) return;                       // no entitled chapter context → never load
2652  if (!offset && btn && btn.dataset.loaded === '1') {      // already fetched → just toggle visibility
2653    const showing = container.style.display !== 'none';
2654    container.style.display = showing ? 'none' : '';
2655    btn.setAttribute('aria-expanded', showing ? 'false' : 'true');
2656    btn.textContent = showing ? (btn.dataset.label || 'View replies') : 'Hide replies';
2657    return;
2658  }
2659  if (btn) { btn.dataset.label = btn.textContent; btn.disabled = true; }
2660  container.style.display = '';
2661  if (!offset) container.innerHTML = '<div class="loading">Loading replies…</div>';
2662  try {
2663    const { data, error } = await supabase
2664      .from('chapter_comments')
2665      .select('id, content, created_at, likes_count, replies_count, profiles!chapter_comments_user_id_fkey ( id, username, display_name, avatar_url, is_banned, role, roles, badge_role )')
2666      .eq('chapter_id', chapterId)              // SAFETY: pin to the entitled chapter
2667      .eq('parent_id', parentId)
2668      .order('created_at', { ascending: true })  // oldest-first (mobile parity)
2669      .range(offset, offset + 19);
2670    if (error) throw error;
2671    if (_readerCommentsChapterId !== chapterId || identity !== _readerIdentity() || !container.isConnected) return;
2672    const rows = (data || [])
2673      .filter(r => !(r.profiles && r.profiles.is_banned === true))   // hide banned
2674      .filter(r => String(r.content || '').trim().length > 0);       // drop empty/whitespace
2675    container.querySelector('[data-replies-more]')?.remove();
2676    const markup = rows.map(row => _renderChapterCommentItem(row, true)).join('');
2677    if (offset) container.insertAdjacentHTML('beforeend', markup);
2678    else container.innerHTML = markup || '<div class="book-section-empty">No replies.</div>';
2679    if ((data || []).length === 20) {
2680      const more = document.createElement('button'); more.type = 'button'; more.className = 'reader-tool'; more.dataset.repliesMore = '1'; more.textContent = 'Load more replies';
2681      more.onclick = () => { more.disabled = true; _loadCommentReplies(parentId, btn, container, offset + 20); }; container.append(more);
2682    }
2683    if (btn) { btn.dataset.loaded = '1'; btn.disabled = false; btn.setAttribute('aria-expanded', 'true'); btn.textContent = 'Hide replies'; }
2684  } catch (e) {
2685    if (_readerCommentsChapterId !== chapterId || identity !== _readerIdentity() || !container.isConnected) return;
2686    if (offset) { const more = container.querySelector('[data-replies-more]'); if (more) { more.disabled = false; more.textContent = 'Retry loading replies'; } }
2687    else container.innerHTML = '<div class="book-section-empty">Couldn’t load replies.</div>';
2688    if (btn) btn.disabled = false;
2689  }
2690}
2691
2692function renderBookDetail() {
2693  if (!currentBookDetail) return;
2694  const { book, chapters } = currentBookDetail;
2695  // Use the same published chapter metadata as the table of contents. The
2696  // book-level aggregate can lag chapter edits or include unpublished work.
2697  const chapterWordCount = chapter => {
2698    const count = Number(chapter.word_count);
2699    return Number.isSafeInteger(count) && count >= 0 ? count : 0;
2700  };
2701  const publishedWordCount = chapters.reduce((total, chapter) => total + chapterWordCount(chapter), 0);
2702  const content = document.getElementById('bookDetailContent');
2703
2704  const authorName = book.profiles?.username || 'Unknown';
2705  const authorAvatar = book.profiles?.avatar_url;
2706  const initialLetter = (book.title || '?').trim().charAt(0).toUpperCase();
2707  // Detail page is the biggest cover in the app — pre-crop to a larger
2708  // 2:3 (600×900) so the rendered image stays sharp. Supabase will serve
2709  // the optimised image; Bunny/external pass through and rely on CSS.
2710  const croppedCover = book.cover_url
2711    ? _cfg._supabaseRatioCrop(_cfg._cleanCdnUrl(book.cover_url), { width: 600, height: 900 })
2712    : '';
2713  const cover = book.cover_url
2714    ? `<img src="${escHTML(croppedCover)}" alt=""/>`
2715    : `<div class="book-cover-placeholder" style="width:100%;height:100%">${initialLetter}</div>`;
2716
2717  const tagsHtml = (book.tags || []).map(t =>
2718    `<button class="book-chip" data-tag="${escHTML(t)}" type="button">${escHTML(t)}</button>`
2719  ).join('');
2720
2721  // ─── Owner bypass ────────────────────────────────────────────────────
2722  // Mirrors mobile's BookUnlocksServiceSupabase.isBookOwnedByUser
2723  // (lib/book-unlocks.js:79-87). Authors should never be paywalled on
2724  // their own books — they don't need to "unlock" content they wrote.
2725  //
2726  // We still SHOW the lock badge in the TOC (informational — the author
2727  // wants to see which chapters readers will pay for), matching mobile's
2728  // split between isChapterLockedForDisplay (badges) and isChapterLocked
2729  // (paywall enforcement). The owner-bypass only suppresses enforcement,
2730  // not visual signal. The bulk-unlock CTA is hidden though — "Unlock
2731  // all 5 chapters for X coins" is wrong UX for the owner.
2732  const _viewer = _cfg.getCurrentProfile?.();
2733  const _viewerId = _viewer?.id || null;
2734  const isOwnerViewing = !!(_viewerId && book?.author_id && _viewerId === book.author_id);
2735
2736  // Display-only lock check — used for the lock badge in the TOC row.
2737  // Authors see badges on their own paid chapters (matches mobile's
2738  // isChapterLockedForDisplay). Two-signal logic:
2739  //   • Book-level threshold: book.lock_from_chapter is set AND
2740  //     chapter.chapter_number >= lock_from_chapter
2741  //   • Per-chapter override:  chapter.is_locked === true
2742  // …minus already-unlocked chapters.
2743  const lockFrom = book.lock_from_chapter || null;
2744  const isChapterLockedForReader = (c) => {
2745    if (!lockFrom && !c.is_locked) return false;
2746    const isAtOrAfterLockPoint = lockFrom != null && c.chapter_number >= lockFrom;
2747    if (!isAtOrAfterLockPoint && !c.is_locked) return false;
2748    const realId = c.id.startsWith('sb_') ? c.id.slice(3) : c.id;
2749    return !_cfg.isUnlocked('chapter', realId);
2750  };
2751
2752  const stillLockedChapters = chapters.filter(isChapterLockedForReader);
2753  const lockedChapterCount = stillLockedChapters.length;
2754
2755  // BX-8A-3: premium/paid chapters are Mobile-App-Only on web for normal readers, so the website
2756  // must NOT offer a web unlock path for them. _isWebMobileOnlyChapter() already bypasses
2757  // author/admin/moderator, so this is true ONLY for a normal reader viewing a premium chapter —
2758  // in which case we suppress the bulk-unlock CTA (the mobile-only gate routes them to the app).
2759  // Author (also covered by isOwnerViewing) and admin/mod are unaffected.
2760  const webUnlockSuppressed = stillLockedChapters.some(c => _isWebMobileOnlyChapter(c, book));
2761
2762  const chaptersHtml = chapters.length
2763    ? chapters.map(c => {
2764        const locked = isChapterLockedForReader(c);
2765        const mobileOnly = _isWebMobileOnlyChapter(c, book);   // BX-8A: premium → web mobile-only badge
2766        const lockBadge = locked
2767          ? `<span class="chapter-row-lock" title="${mobileOnly ? 'Premium — read in the Selebox mobile app' : 'Locked — tap to unlock'}">
2768               <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
2769             </span>`
2770          : '';
2771        return `
2772          <div class="chapter-row${locked ? ' is-locked' : ''}" data-chapter-id="${c.id}">
2773            <span class="chapter-row-num">#${c.chapter_number}</span>
2774            ${lockBadge}
2775            <span class="chapter-row-title">${escHTML(c.title || `#${c.chapter_number}`)}</span>
2776            <span class="chapter-row-meta">${chapterWordCount(c).toLocaleString()} words</span>
2777            ${partLiveDate(c) ? `<time class="chapter-row-meta chapter-row-live-date" datetime="${escHTML(partLiveDate(c))}">${escHTML(new Date(partLiveDate(c)).toLocaleDateString())}</time>` : ''}
2778            <span class="chapter-row-meta chapter-row-cc"></span>
2779            ${mobileOnly ? `<span class="chapter-row-meta" style="color:var(--accent,#fbbf24)" title="Premium — read in the Selebox mobile app">📱 Mobile app only</span>` : ''}
2780          </div>
2781        `;
2782      }).join('')
2783    : '<div style="color:var(--text2);padding:1rem 0">No chapters published yet.</div>';
2784
2785  // Bulk unlock CTA — shown only when at least 1 chapter is currently locked.
2786  //
2787  // Cost is the SUM of each still-locked chapter's resolved price (per-chapter
2788  // override OR global default), then the bulk discount applied. This mirrors
2789  // what mobile does in BookChaptersUnlockModal.jsx (sumLockedChaptersCost).
2790  // The previous implementation multiplied the global default by lockedChapterCount,
2791  // which ignored the per-chapter overrides entirely — an author who set
2792  // 3 coins per chapter saw a bulk price computed off the 25-coin global default.
2793  const bulkDiscount = _cfg.getWalletConfig().book_bulk_unlock_discount_pct ?? 15;
2794  const bulkBefore = stillLockedChapters.reduce(
2795    (sum, c) => sum + (_cfg.resolveUnlockCost('chapter', 'coin', c) || 0),
2796    0,
2797  );
2798  const bulkStarBefore = stillLockedChapters.reduce(
2799    (sum, c) => sum + (_cfg.resolveUnlockCost('chapter', 'star', c) || 0),
2800    0,
2801  );
2802  const bulkCoin = Math.max(1, bulkBefore - Math.floor((bulkBefore * bulkDiscount) / 100));
2803  const bulkStar = Math.max(1, bulkStarBefore - Math.floor((bulkStarBefore * bulkDiscount) / 100));
2804  // Owners never see the bulk-unlock CTA — they own the book, asking
2805  // them to pay to unlock it is nonsense. The lock badges in the TOC
2806  // remain (informational).
2807  const bulkUnlockCard = (lockedChapterCount > 0 && !isOwnerViewing && !webUnlockSuppressed) ? `
2808    <div class="book-bulk-unlock">
2809      <div class="book-bulk-unlock-icon">📚</div>
2810      <div class="book-bulk-unlock-meta">
2811        <div class="book-bulk-unlock-title">Unlock all <strong>${lockedChapterCount}</strong> locked chapter${lockedChapterCount === 1 ? '' : 's'}</div>
2812        <div class="book-bulk-unlock-sub">Save ${bulkDiscount}% vs unlocking one by one</div>
2813      </div>
2814      <button class="btn btn-purple" id="btnBulkUnlockBook" data-locked-count="${lockedChapterCount}" data-coin="${bulkCoin}" data-star="${bulkStar}">
2815        ${bulkCoin} coin${bulkCoin === 1 ? '' : 's'} or ${bulkStar} star${bulkStar === 1 ? '' : 's'}
2816      </button>
2817    </div>
2818  ` : '';
2819
2820  content.innerHTML = `
2821    <div class="book-detail">
2822      <div class="book-detail-cover">${cover}</div>
2823      <div class="book-detail-info">
2824        <h1>${escHTML(book.title || 'Untitled')}</h1>
2825        <button class="book-detail-author book-detail-author-link" data-author-id="${escHTML(book.profiles?.id || book.author_id || '')}" type="button" title="View author profile">
2826          <div class="avatar">${authorAvatar ? `<img src="${escHTML(authorAvatar)}"/>` : initials(authorName)}</div>
2827          <span>by <strong>${escHTML(authorName)}</strong>${renderDisplayBadge(book.profiles)}</span>
2828        </button>
2829        <div class="book-detail-meta">
2830          <span><strong>${chapters.length.toLocaleString()}</strong> Parts${isOwnerViewing && Number(book.chapters_count)>chapters.length ? `<small class="book-scheduled-count">+${Math.max(0,Number(book.chapters_count)-chapters.length)} scheduled</small>` : ''}</span>
2831          <span><strong>${publishedWordCount.toLocaleString()}</strong> words</span>
2832          <span><strong>${(book.views_count || 0).toLocaleString()}</strong> views</span>
2833          <span><strong>${(book.likes_count || 0).toLocaleString()}</strong> likes</span>
2834          <span id="bookCommentCountStat" style="display:none"></span>
2835          ${_ratingDetailSpan(book)}
2836        </div>
2837        ${_renderBookMetaBadges(book, { chapters })}
2838        <div class="book-detail-genre-row">
2839          ${book.genre ? `<button class="book-chip active" type="button">${escHTML(book.genre.replace(/-/g, ' '))}</button>` : ''}
2840          ${tagsHtml}
2841        </div>
2842        <div class="book-detail-actions">
2843          <button class="btn btn-purple btn-sm" id="btnStartReading" ${chapters.length ? '' : 'disabled style="opacity:0.5;cursor:not-allowed"'}>
2844            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
2845            Start reading
2846          </button>
2847          <button class="btn btn-ghost btn-sm book-action-btn" id="btnLikeBook" data-active="0">
2848            <svg class="book-action-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
2849            <span class="book-action-label">Like</span>
2850            <span class="book-action-count" id="btnLikeBookCount">${(book.likes_count || 0).toLocaleString()}</span>
2851          </button>
2852          <button class="btn btn-ghost btn-sm book-action-btn" id="btnBookmarkBook" data-active="0">
2853            <svg class="book-action-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
2854            <span class="book-action-label">Bookmark</span>
2855          </button>
2856          <button class="btn btn-ghost btn-sm book-action-btn" id="btnShareBook" type="button">
2857            <svg class="book-action-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
2858            <span class="book-action-label">Share</span>
2859          </button>
2860          ${(_viewerId && !isOwnerViewing) ? `
2861            <button class="btn btn-ghost btn-sm book-action-btn" id="btnFollowAuthor" type="button" data-following="0" data-author-id="${escHTML(book.profiles?.id || book.author_id || '')}">
2862              <svg class="book-action-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>
2863              <span class="book-action-label">Follow</span>
2864            </button>
2865          ` : ''}
2866          ${(_cfg.getCurrentProfile()?.role === 'admin' || _cfg.getCurrentProfile()?.role === 'moderator') ? `
2867            <button class="btn btn-sm book-action-btn book-editors-pick-btn ${book.is_editors_pick ? 'is-picked' : ''}" id="btnEditorsPick" data-picked="${book.is_editors_pick ? '1' : '0'}" title="${book.is_editors_pick ? "Remove Editor's Pick" : "Mark as Editor's Pick"}">
2868              <svg width="16" height="16" viewBox="0 0 24 24" fill="${book.is_editors_pick ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
2869              <span class="book-action-label">${book.is_editors_pick ? "Editor's Pick" : 'Pick'}</span>
2870            </button>
2871          ` : ''}
2872        </div>
2873        <div class="book-detail-description">${escHTML(book.description || 'No description provided.')}</div>
2874        ${bulkUnlockCard}
2875        <div class="chapter-list">
2876          <div class="chapter-list-title">Chapters</div>
2877          ${chaptersHtml}
2878        </div>
2879        <div class="book-section" id="bookReviewsSection" style="display:none;margin-top:1.5rem">
2880          <div class="book-section-head"><h2 class="book-section-title">Reviews</h2></div>
2881          <div id="bookReviewsList"></div>
2882        </div>
2883      </div>
2884    </div>
2885  `;
2886
2887  // Sanity check: the count of `.chapter-row.is-locked` DOM nodes must
2888  // equal `lockedChapterCount` (the number used by the bulk-unlock CTA).
2889  // If the two ever disagree, the bulk button will charge for a different
2890  // set of chapters than the user can see locked — exactly the kind of
2891  // bug we just fixed (and want a guard against re-introducing).
2892  //
2893  // We log a console warning rather than throwing so a divergence in
2894  // production surfaces in dev tools without breaking the user's flow.
2895  // The wallet-config snapshot (getWalletConfig() → wallet.js app_config
2896  // snapshot) and lock detection both live in this function, so any
2897  // future logic change touching either will be caught here on the
2898  // next render.
2899  const renderedLockCount = content.querySelectorAll('.chapter-row.is-locked').length;
2900  if (renderedLockCount !== lockedChapterCount) {
2901    console.warn(
2902      `[lock-count-mismatch] book ${book.id}: rendered ${renderedLockCount} lock icons but bulk CTA says ${lockedChapterCount}. ` +
2903      `Lock detection has diverged between row render and stillLockedChapters filter — check isChapterLockedForReader callers.`
2904    );
2905  }
2906
2907  // Wire chapter rows
2908  content.querySelectorAll('.chapter-row').forEach((row, i) => {
2909    row.addEventListener('click', () => openChapterReader(i));
2910  });
2911
2912  content.querySelector('#btnShareBook')?.addEventListener('click', () => _shareBook(book.id, book.title));
2913
2914  // Wire clickable author → opens their profile
2915  content.querySelector('.book-detail-author-link')?.addEventListener('click', (e) => {
2916    const authorId = e.currentTarget.dataset.authorId;
2917    if (authorId) _cfg.openProfile(authorId);
2918  });
2919
2920  // Bulk-unlock button — opens a modal with coin/star choice + final price
2921  document.getElementById('btnBulkUnlockBook')?.addEventListener('click', () => {
2922    _cfg.openBulkBookUnlockDialog({
2923      bookId:        book.id,
2924      bookTitle:     book.title || 'this book',
2925      lockedCount:   parseInt(document.getElementById('btnBulkUnlockBook').dataset.lockedCount, 10) || lockedChapterCount,
2926      coinCost:      parseInt(document.getElementById('btnBulkUnlockBook').dataset.coin, 10) || bulkCoin,
2927      starCost:      parseInt(document.getElementById('btnBulkUnlockBook').dataset.star, 10) || bulkStar,
2928      discountPct:   bulkDiscount,
2929      onUnlocked:    () => openBookDetail(book.id),
2930    });
2931  });
2932  // Start reading → first unread chapter (or chapter 1)
2933  document.getElementById('btnStartReading')?.addEventListener('click', () => openChapterReader(0));
2934
2935  // Wire like + bookmark
2936  const likeBtn = document.getElementById('btnLikeBook');
2937  const bookmarkBtn = document.getElementById('btnBookmarkBook');
2938  likeBtn?.addEventListener('click', () => toggleBookLike(book.id));
2939  bookmarkBtn?.addEventListener('click', () => toggleBookBookmark(book.id));
2940
2941  // BX-4: author follow (rendered only for signed-in non-author viewers, so a
2942  // logged-out viewer never queries/writes follows). Reuses the secure `follows`
2943  // RLS write; default label is 'Follow', async-corrected to 'Following'.
2944  const followBtn = document.getElementById('btnFollowAuthor');
2945  if (followBtn) {
2946    const followAuthorId = book.profiles?.id || book.author_id || null;
2947    followBtn.addEventListener('click', () => _toggleBookAuthorFollow(followAuthorId, followBtn));
2948    _isFollowingAuthor(followAuthorId).then(following => { if (following) _setFollowButtonState(followBtn, true); });
2949  }
2950
2951  // Editor's Pick toggle (mods/admins only — button is gated server-side too)
2952  document.getElementById('btnEditorsPick')?.addEventListener('click', async (e) => {
2953    const btn = e.currentTarget;
2954    const currentlyPicked = btn.dataset.picked === '1';
2955    const willPick = !currentlyPicked;
2956    btn.disabled = true;
2957    let note = null;
2958    if (willPick) {
2959      // Optional editorial note — short blurb that shows up in tooltips/lists
2960      note = prompt('Optional — short editorial note (why is this an Editor\'s Pick?):', '') || null;
2961    }
2962    try {
2963      const { data, error } = await supabase.rpc('set_editors_pick', {
2964        p_book_id: book.id,
2965        p_pick: willPick,
2966        p_note: note,
2967      });
2968      if (error) throw error;
2969      if (!data?.ok) throw new Error(data?.error || 'Failed to update Editor\'s Pick');
2970      // Update UI inline
2971      btn.dataset.picked = willPick ? '1' : '0';
2972      btn.classList.toggle('is-picked', willPick);
2973      btn.querySelector('.book-action-label').textContent = willPick ? "Editor's Pick" : 'Pick';
2974      btn.querySelector('svg').setAttribute('fill', willPick ? 'currentColor' : 'none');
2975      btn.title = willPick ? "Remove Editor's Pick" : "Mark as Editor's Pick";
2976      // Mutate cached book so re-renders stay in sync
2977      if (currentBookDetail?.book) {
2978        currentBookDetail.book.is_editors_pick = willPick;
2979        currentBookDetail.book.editors_pick_at = willPick ? new Date().toISOString() : null;
2980        currentBookDetail.book.editors_pick_note = willPick ? note : null;
2981      }
2982      toast(willPick ? 'Marked as Editor\'s Pick ⭐' : 'Removed from Editor\'s Pick', 'success');
2983    } catch (err) {
2984      toast(err.message || String(err), 'error');
2985    } finally {
2986      btn.disabled = false;
2987    }
2988  });
2989
2990  // Load initial state (whether the user has already liked/bookmarked)
2991  loadBookActionState(book.id);
2992
2993  // BX-7A: load book reviews (read-only book_ratings.review) into the Reviews
2994  // section. Async; works logged-out (book_ratings SELECT is public). No writes.
2995  _loadBookReviews(book.id);
2996
2997  // BX-7B-3: load per-chapter + total top-level comment COUNTS (read-only, counts only,
2998  // no bodies) for the displayed published chapters. Async; stale-guarded; no realtime.
2999  _loadChapterCommentCounts(book.id);
3000}
3001
3002// ────────────────────────────────────────────────────────────────────────
3003// Load initial like/bookmark state for the current book + render visual
3004// ────────────────────────────────────────────────────────────────────────
3005async function loadBookActionState(bookId) {
3006  if (!_cfg.getCurrentUser()) return;
3007  try {
3008    const [{ data: like }, { data: bm }] = await Promise.all([
3009      supabase.from('book_likes').select('book_id').eq('user_id', _cfg.getCurrentUser().id).eq('book_id', bookId).maybeSingle(),
3010      supabase.from('book_bookmarks').select('book_id').eq('user_id', _cfg.getCurrentUser().id).eq('book_id', bookId).maybeSingle(),
3011    ]);
3012    setBookActionActive('btnLikeBook',     !!like);
3013    setBookActionActive('btnBookmarkBook', !!bm);
3014  } catch (e) { /* non-fatal */ }
3015}
3016
3017function setBookActionActive(buttonId, active) {
3018  const btn = document.getElementById(buttonId);
3019  if (!btn) return;
3020  btn.dataset.active = active ? '1' : '0';
3021  // Fill the SVG icon when active (heart filled, bookmark filled)
3022  const icon = btn.querySelector('.book-action-icon');
3023  if (icon) icon.setAttribute('fill', active ? 'currentColor' : 'none');
3024  // Update label
3025  const label = btn.querySelector('.book-action-label');
3026  if (label) {
3027    if (buttonId === 'btnLikeBook')      label.textContent = active ? 'Liked' : 'Like';
3028    if (buttonId === 'btnBookmarkBook')  label.textContent = active ? 'Saved' : 'Bookmark';
3029  }
3030}
3031
3032async function toggleBookLike(bookId) {
3033  if (!_cfg.getCurrentUser()) { toast('Sign in to like books', 'error'); return; }
3034  const btn  = document.getElementById('btnLikeBook');
3035  const wasActive = btn?.dataset.active === '1';
3036  const countEl   = document.getElementById('btnLikeBookCount');
3037
3038  // Optimistic UI — flip immediately
3039  setBookActionActive('btnLikeBook', !wasActive);
3040  if (countEl) {
3041    const cur = parseInt(countEl.textContent.replace(/[^\d]/g, ''), 10) || 0;
3042    const next = wasActive ? Math.max(0, cur - 1) : cur + 1;
3043    countEl.textContent = next.toLocaleString();
3044    if (currentBookDetail?.book) currentBookDetail.book.likes_count = next;
3045  }
3046
3047  try {
3048    if (wasActive) {
3049      const { error } = await supabase.from('book_likes')
3050        .delete()
3051        .eq('user_id', _cfg.getCurrentUser().id)
3052        .eq('book_id', bookId);
3053      if (error) throw error;
3054    } else {
3055      const { error } = await supabase.from('book_likes')
3056        .insert({ user_id: _cfg.getCurrentUser().id, book_id: bookId });
3057      // Ignore duplicate-key (already liked from another tab); other errors throw
3058      if (error && !/duplicate|unique/i.test(error.message)) throw error;
3059    }
3060  } catch (e) {
3061    // Revert optimistic UI on error
3062    setBookActionActive('btnLikeBook', wasActive);
3063    if (countEl) {
3064      const cur = parseInt(countEl.textContent.replace(/[^\d]/g, ''), 10) || 0;
3065      countEl.textContent = (wasActive ? cur + 1 : Math.max(0, cur - 1)).toLocaleStr
3065ing();
3066    }
3067    toast('Failed: ' + (e.message || e), 'error');
3068  }
3069}
3070
3071async function toggleBookBookmark(bookId) {
3072  if (!_cfg.getCurrentUser()) { toast('Sign in to bookmark books', 'error'); return; }
3073  const btn = document.getElementById('btnBookmarkBook');
3074  const wasActive = btn?.dataset.active === '1';
3075
3076  // Optimistic UI
3077  setBookActionActive('btnBookmarkBook', !wasActive);
3078
3079  try {
3080    if (wasActive) {
3081      const { error } = await supabase.from('book_bookmarks')
3082        .delete()
3083        .eq('user_id', _cfg.getCurrentUser().id)
3084        .eq('book_id', bookId);
3085      if (error) throw error;
3086    } else {
3087      const { error } = await supabase.from('book_bookmarks')
3088        .insert({ user_id: _cfg.getCurrentUser().id, book_id: bookId });
3089      if (error && !/duplicate|unique/i.test(error.message)) throw error;
3090    }
3091  } catch (e) {
3092    setBookActionActive('btnBookmarkBook', wasActive);
3093    toast('Failed: ' + (e.message || e), 'error');
3094  }
3095}
3096
3097// ════════════════════════════════════════════════════════════════════════
3098// Books Fix Pack 1 — chapter-reader safety helpers (2026-06-15)
3099// ════════════════════════════════════════════════════════════════════════
3100//
3101// sanitizeChapterHtml(): allowlist sanitizer for author-authored chapter HTML
3102// (Quill output) rendered into the reader via innerHTML. Strips script /
3103// iframe / object / embed / svg / math / form / on* handlers / javascript: &
3104// unsafe data: URLs / unknown tags / inline styles, while preserving normal
3105// reader formatting. Render-side only in this pack — the Quill author save
3106// path (in app.js) is intentionally NOT touched here.
3107// figure/figcaption carry canonical Writer's Lounge images; without them the
3108// reader re-flattens a correctly stored figure into a bare <img>.
3109const _CHAPTER_ALLOWED_TAGS = ['p','br','div','h1','h2','h3','h4','h5','h6','strong','b','em','i','u','s','blockquote','ul','ol','li','a','img','span','figure','figcaption'];
3110// The twelve canonical attributes are named explicitly — ALLOW_DATA_ATTR stays
3111// false, so no other data-* survives. enforceCanonicalFigureTrust() below then
3112// strips these again from any figure whose metadata does not validate.
3113const _CHAPTER_ALLOWED_ATTR = ['href','src','alt','title','class', ...CANONICAL_NOVEL_IMAGE_ATTRS];
3114const _CHAPTER_FORBID_TAGS  = ['script','iframe','object','embed','svg','math','form','input','button','textarea','style'];
3115
3116// `class` is allowed only so Quill alignment/indent classes survive; this
3117// hook drops every non-`ql-` class and removes the attribute if nothing's
3118// left. Inline `style` is stripped outright (FORBID_ATTR below).
3119let _chapterHookInstalled = false;
3120function _ensureChapterSanitizeHook() {
3121  if (_chapterHookInstalled || !DOMPurify || typeof DOMPurify.addHook !== 'function') return;
3122  DOMPurify.addHook('afterSanitizeAttributes', (node) => {
3123    if (node && node.getAttribute && node.getAttribute('class')) {
3124      const kept = node.getAttribute('class').split(/\s+/).filter((c) => c.startsWith('ql-')).join(' ');
3125      if (kept) node.setAttribute('class', kept);
3126      else node.removeAttribute('class');
3127    }
3128    // Enforce http(s)/mailto on URL attributes. ALLOWED_URI_REGEXP doesn't
3129    // cover data: on DOMPurify's DATA_URI_TAGS (e.g. <img src="data:…svg+xml">),
3130    // so strip any non-http(s)/mailto src/href here to honor the "no unsafe
3131    // data:" rule.
3132    for (const attr of ['src', 'href', 'xlink:href']) {
3133      if (node && node.hasAttribute && node.hasAttribute(attr)) {
3134        const v = (node.getAttribute(attr) || '').trim();
3135        if (!/^(?:https?:|mailto:)/i.test(v)) node.removeAttribute(attr);
3136      }
3137    }
3138    // The canonical attributes are allowlisted by name, but DOMPurify does not
3139    // validate data-*-url values. Anything that fails the contract loses its
3140    // canonical metadata here and renders as an ordinary figure.
3141    enforceCanonicalFigureTrust(node);
3142  });
3143  _chapterHookInstalled = true;
3144}
3145_ensureChapterSanitizeHook();
3146
3147export function sanitizeChapterHtml(html) {
3148  if (!html) return '';
3149  _ensureChapterSanitizeHook();
3150  return DOMPurify.sanitize(String(html), {
3151    ALLOWED_TAGS: _CHAPTER_ALLOWED_TAGS,
3152    ALLOWED_ATTR: _CHAPTER_ALLOWED_ATTR,
3153    FORBID_TAGS:  _CHAPTER_FORBID_TAGS,
3154    FORBID_ATTR:  ['style'],
3155    ALLOW_DATA_ATTR: false,
3156    ALLOWED_URI_REGEXP: /^(?:https?:|mailto:)/i, // http(s)/mailto only → blocks javascript:, vbscript:, data:
3157    // DOMPurify tests EVERY attribute value against ALLOWED_URI_REGEXP unless
3158    // the NAME is URI-safe. Nine of the twelve canonical attributes hold
3159    // non-URI values (the v1 marker, the asset id, the orientation, six
3160    // dimensions), so naming them in ALLOWED_ATTR was not enough — they were
3161    // dropped, and the surviving tier URLs were then left unvalidated.
3162    // Exempting the names by contract keeps them; enforceCanonicalFigureTrust()
3163    // below remains the security boundary that validates the values.
3164    ADD_URI_SAFE_ATTR: CANONICAL_NOVEL_IMAGE_ATTRS,
3165  });
3166}
3167
3168// decideChapterRender(): pure mapping from a get_chapter_content_if_entitled()
3169// row → what the reader should show. Never exposes locked/denied content.
3170// Exported so it can be unit-tested without a DOM. Returns one of:
3171//   { kind:'content', content }  — entitled, render sanitized content
3172//   { kind:'mobileonly' }        — reason/web_status 'mobile_only' → BX-8A app-only gate (future web-safe RPC)
3173//   { kind:'paywall' }           — reason 'not_entitled', show unlock CTA
3174//   { kind:'notfound' }          — reason 'not_found'
3175//   { kind:'unavailable' }       — reason 'not_published' / 'not_public' / other
3176//   { kind:'error' }             — no row / transport failure
3177export function decideChapterRender(rpcRow) {
3178  if (!rpcRow) return { kind: 'error' };
3179  if (rpcRow.entitled === true) return { kind: 'content', content: rpcRow.content || '' };
3180  const reason = rpcRow.reason || 'not_entitled';
3181  // BX-8B-C-WEB: the future DB-owned get_chapter_content_for_web (Phase C) will return
3182  // reason/web_status='mobile_only' for paid chapters that reach the RPC. Map it to a
3183  // dedicated kind so the reader shows the same BX-8A "Premium Chapter (Mobile Only)" gate
3184  // instead of the generic 'unavailable' message. Not normally hit today — the BX-8A
3185  // front-gate catches paid chapters before the RPC — but keeps the reader forward-compatible.
3186  if (reason === 'mobile_only' || rpcRow.web_status === 'mobile_only') return { kind: 'mobileonly', reason };
3187  if (reason === 'not_entitled') return { kind: 'paywall', reason };
3188  if (reason === 'not_found')    return { kind: 'notfound', reason };
3189  return { kind: 'unavailable', reason };
3190}
3191
3192// BX-8B-C-WEB: reusable "Premium Chapter (Mobile Only)" gate renderer. Extracted from
3193// the inline BX-8A front-gate so the SAME UI can be shown from two call-sites:
3194//   (1) the BX-8A front-gate in openChapterReader — BEFORE the content RPC (the live,
3195//       defense-in-depth path today), and
3196//   (2) the decideChapterRender → { kind:'mobileonly' } branch, which prepares the reader
3197//       for the future DB-owned get_chapter_content_for_web RPC (Phase C) that returns
3198//       reason/web_status='mobile_only'. That branch is not normally reached today.
3199// Frontend deterrence ONLY — does NOT close the backend P0 (direct chapters.content read).
3200// Back-to-Book-Details behavior is preserved verbatim from BX-8A. BX-8A-2: the "Download" CTA
3201// now links to the real App Store + Google Play listings (official PM-provided URLs below) via
3202// standard target=_blank anchors — still NO deep links / custom URL schemes (selebox://,
3203// talesofsiren://, intent://) and NO QR. `options` is reserved for future use (e.g. a
3204// reason/analytics tag passed by the web-safe RPC branch).
3205// BX-8A-2: official Selebox app store listings (PM-provided — do NOT modify these URLs).
3206const SELEBOX_PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=com.talesofsiren.talesofsiren&pcampaignid=web_share';
3207const SELEBOX_APP_STORE_URL  = 'https://apps.apple.com/ph/app/selebox/id6736897349SeLeBox';
3208
3209function _renderReaderMobileOnlyGate(options = {}) {
3210  const content = document.getElementById('readerContent');
3211  if (!content) return;
3212  content.style.fontSize = '';
3213  content.innerHTML = `
3214    <div class="reader-paywall">
3215      <div class="reader-paywall-icon">📱</div>
3216      <h2>Premium Chapter (Mobile Only)</h2>
3217      <p>To protect our writers from screenshots, copying, and unauthorized resale of paid stories, premium chapters can no longer be read on the website. Free chapters are still available here.</p>
3218      <p style="margin:.2rem 0 .5rem">Read premium chapters in the Selebox mobile app — download it to continue on your phone.</p>
3219      <div style="display:flex;flex-wrap:wrap;gap:.5rem;justify-content:center;margin:.2rem 0">
3220        <a class="btn btn-purple" href="${SELEBOX_APP_STORE_URL}" target="_blank" rel="noopener noreferrer" style="text-decoration:none">Download on the App Store</a>
3221        <a class="btn btn-purple" href="${SELEBOX_PLAY_STORE_URL}" target="_blank" rel="noopener noreferrer" style="text-decoration:none">Get it on Google Play</a>
3222      </div>
3223      <button class="btn btn-ghost btn-sm" id="btnReaderBackToBook" type="button">Back to Book Details</button>
3224      <p style="font-size:.82rem;color:var(--text2);margin-top:.6rem">If you already unlocked this chapter, your access remains valid in the mobile app.</p>
3225    </div>
3226  `;
3227  // BX-8A-2: the App Store / Google Play CTAs are real <a target="_blank"> links (PM-provided
3228  // official store URLs) — they navigate natively, so no JS click handler is needed for them.
3229  // Only Back-to-Book-Details needs wiring.
3230  document.getElementById('btnReaderBackToBook')?.addEventListener('click', () => openBookDetail(currentBookDetail.book.id));
3231}
3232
3233// ── Chapter reader ──
3234async function openChapterReader(chapterIndex) {
3235  if (!currentBookDetail || !currentBookDetail.chapters[chapterIndex]) return;
3236  // Flush any in-progress chapter dwell BEFORE we mutate the
3237  // module-scoped open-state below. Without this, navigating
3238  // chapter-to-chapter would lose the previous chapter's close event.
3239  flushReadClose();
3240  currentChapterIndex = chapterIndex;
3241  const chapter = currentBookDetail.chapters[chapterIndex];
3242
3243  _cfg.hideAllMainPages();
3244  const generation = ++_readerRequestGeneration;
3245  const identity = _readerIdentity();
3246  const bookDetail = currentBookDetail;
3247  const bookId = bookDetail.book.id;
3248  const chapterId = chapter.id;
3249  const isCurrent = () => generation === _readerRequestGeneration
3250    && identity === _readerIdentity()
3251    && currentBookDetail === bookDetail
3252    && currentBookDetail?.book?.id === bookId
3253    && currentChapterIndex === chapterIndex
3254    && currentBookDetail?.chapters[chapterIndex]?.id === chapterId;
3255  chapterReaderPage.style.display = 'block';
3256  _applyReaderSettings();   // BX-6A: re-assert persisted theme/line-spacing on every open (idempotent)
3257
3258  // (Engagement counters + anti-fraud telemetry used to fire here, BEFORE
3259  // the paywall check. That counted a paywall view as a "read" for both
3260  // the daily goal AND the dwell-distribution telemetry — a user who only
3261  // saw the lock CTA got farmed as if they read the chapter. Moved to
3262  // after the paywall check below — Codex P1.)
3263
3264  document.getElementById('readerBookTitle').textContent = currentBookDetail.book.title || 'Book';
3265  document.getElementById('readerChapterTitle').textContent = chapter.title || `Chapter ${chapter.chapter_number}`;
3266  document.getElementById('readerProgress').textContent = `Part ${chapter.chapter_number} of ${currentBookDetail.chapters.length}`;
3267  document.getElementById('btnReaderPrev').disabled = chapterIndex <= 0;
3268  document.getElementById('btnReaderNext').disabled = chapterIndex >= currentBookDetail.chapters.length - 1;
3269
3270  // Path-based deep-link-friendly chapter URL — keeps mobile Universal
3271  // Links / App Links able to pick this URL up if shared.
3272  history.pushState(null, '', `/books/${currentBookDetail.book.id}/chapter/${chapter.chapter_number}`);
3273
3274  const content = document.getElementById('readerContent');
3275  content.style.fontSize = '';
3276  content.innerHTML = '<div class="loading">Loading chapter...</div>';
3277  _resetReaderComments();   // BX-7B-1: hide Comments button until (and unless) entitled content renders
3278
3279  // ── BX-8A: premium chapters are Selebox-mobile-app-only on web (anti-theft) ────────────────
3280  // Runs BEFORE the content RPC, covering EVERY entry point (row click / Start / TOC / prev-next /
3281  // Continue Reading / direct deep-link all route through here). For a normal web reader on a
3282  // premium chapter we overwrite #readerContent (purging the "Loading…" / any prior chapter text)
3283  // with the mobile-only gate and return early: NO get_chapter_content_if_entitled call, NO chapter
3284  // content in the DOM, comments button stays hidden (already reset above), NO scroll restore, NO
3285  // progress save. Author / admin / moderator bypass (see _isWebMobileOnlyChapter). Frontend
3286  // deterrence only — the content RPC remains the server-side authority (BX-8B = true enforcement).
3287  if (_isWebMobileOnlyChapter(chapter, currentBookDetail.book)) {
3288    _renderReaderMobileOnlyGate();   // BX-8B-C-WEB: same gate UI, now via the shared helper
3289    return;
3290  }
3291
3292  // ── Entitlement-gated content fetch (Books Fix Pack 1, 2026-06-15) ──
3293  // SECURITY: the reader must NOT select chapters.content directly. Doing so
3294  // shipped locked/paid (and unpublished/hidden) content to unentitled clients
3295  // with only a client-side paywall hiding it. get_chapter_content_if_entitled
3296  // (SECURITY DEFINER) returns content ONLY for entitled callers — free public
3297  // chapter, owner/author, admin/moderator, or an unlocked user — otherwise it
3298  // returns null content with a reason. We NEVER fall back to a direct content
3299  // read for a locked or entitlement-denied chapter. Lock pricing for the
3300  // paywall CTA comes from the already-loaded TOC row (`chapter`), not content.
3301  const realChapterId = chapter.id.startsWith('sb_') ? chapter.id.slice(3) : chapter.id;
3302  let resolvedChapterId = realChapterId;
3303  let decision;
3304  try {
3305    // BX-8B-C-WEB: the live call-site INTENTIONALLY stays on get_chapter_content_if_entitled.
3306    // Phase C (DB-owned) will add get_chapter_content_for_web and switch this single call then;
3307    // switching now would break free-chapter reading because that RPC does not exist yet.
3308    const { data, error } = await supabase.rpc('get_chapter_content_if_entitled', { p_chapter_id: realChapterId });
3309    if (!isCurrent()) return;
3310    if (error) throw error;
3311    const row = Array.isArray(data) ? data[0] : data;
3312    resolvedChapterId = row?.chapter_id || realChapterId;
3313    decision = decideChapterRender(row);
3314  } catch (err) {
3315    if (!isCurrent()) return;
3316    console.error('openChapterReader entitlement fetch failed:', err);
3317    decision = { kind: 'error' };
3318  }
3319
3320  // Transport/RPC failure → safe retry UI; never any raw content.
3321  if (decision.kind === 'error') {
3322    content.style.fontSize = '';
3323    content.innerHTML = `<div class="loading" id="chapterRetry" style="cursor:pointer">${escHTML('Couldn\'t load this chapter. Tap to retry.')}</div>`;
3324    document.getElementById('chapterRetry')?.addEventListener('click', () => openChapterReader(chapterIndex));
3325    return;
3326  }
3327
3328  // BX-8B-C-WEB: future web-safe RPC (get_chapter_content_for_web, Phase C) returns
3329  // mobile_only for paid chapters → show the SAME app-only gate as the BX-8A front-gate.
3330  // Not normally reached today: the front-gate above already catches paid chapters before
3331  // the RPC. Defense-in-depth + forward-compat. NO content, NO comments (already reset), NO save.
3332  if (decision.kind === 'mobileonly') {
3333    _renderReaderMobileOnlyGate();
3334    return;
3335  }
3336
3337  // Locked + not entitled → existing paywall CTA, with NO content delivered.
3338  // Owner / admin / unlocked / free are all resolved server-side by the RPC.
3339  if (decision.kind === 'paywall') {
3340    // BX-8A-3: premium chapters are Mobile-App-Only on web. If one ever reaches this paywall
3341    // fallback (front-gate bypassed, or a future RPC returns not_entitled for a premium chapter),
3342    // show the mobile-only gate (App Store / Google Play) instead of the web coin/star unlock
3343    // modal. _isWebMobileOnlyChapter bypasses author/admin/mod, so their entitled content path is
3344    // unaffected (they resolve to kind:'content' server-side and never reach this branch).
3345    if (_isWebMobileOnlyChapter(chapter, currentBookDetail.book)) {
3346      _renderReaderMobileOnlyGate();
3347      return;
3348    }
3349    // BX-UNLOCK-2: logged-out → sign-in CTA. No unlock dialog, no unlock RPC,
3350    // no content. (Server already returned no content for the anon caller.)
3351    if (!_cfg.getCurrentUser()) {
3352      content.style.fontSize = '';
3353      content.innerHTML = `
3354        <div class="reader-paywall">
3355          <div class="reader-paywall-icon">🔒</div>
3356          <h2>This chapter is locked</h2>
3357          <p>Sign in to read this chapter.</p>
3358          <button class="btn btn-purple" id="btnReaderSignIn">Sign in to read</button>
3359        </div>
3360      `;
3361      document.getElementById('btnReaderSignIn')?.addEventListener('click', () => toast('Sign in to read this chapter', 'error'));
3362      return;
3363    }
3364    const coinCost = _cfg.resolveUnlockCost('chapter', 'coin', chapter);
3365    const starCost = _cfg.resolveUnlockCost('chapter', 'star', chapter);
3366    content.style.fontSize = '';
3367    content.innerHTML = `
3368      <div class="reader-paywall">
3369        <div class="reader-paywall-icon">🔒</div>
3370        <h2>This chapter is locked</h2>
3371        <p>Unlock once to read as many times as you like.</p>
3372        <div class="reader-paywall-pricing">
3373          <span><b>${coinCost}</b> coin${coinCost === 1 ? '' : 's'}</span>
3374          <span class="reader-paywall-or">or</span>
3375          <span><b>${starCost}</b> star${starCost === 1 ? '' : 's'}</span>
3376        </div>
3377        <button class="btn btn-purple" id="btnReaderUnlock">Unlock chapter</button>
3378      </div>
3379    `;
3380    document.getElementById('btnReaderUnlock')?.addEventListener('click', () => {
3381      _cfg.openUnlockDialog({
3382        targetType: 'chapter',
3383        targetId:   resolvedChapterId,
3384        row:        chapter,
3385        title:      chapter.title || `Chapter ${chapter.chapter_number}`,
3386        onUnlocked: () => openChapterReader(chapterIndex),  // re-open after unlock
3387      });
3388    });
3389    return;
3390  }
3391
3392  // Unpublished / scheduled / hidden / not-public / not-found → unavailable,
3393  // with NO content delivered.
3394  if (decision.kind !== 'content') {
3395    const msg = decision.kind === 'notfound'
3396      ? 'This chapter isn\'t available.'
3397      : 'This chapter isn\'t available — it may have been unpublished or hidden.';
3398    content.style.fontSize = '';
3399    content.innerHTML = `<div class="loading">${escHTML(msg)}</div>`;
3400    return;
3401  }
3402
3403  // Entitled → sanitize the author-authored HTML, then inject it. The
3404  // sanitizer strips script/iframe/svg/on*/javascript: while keeping normal
3405  // reader formatting (see sanitizeChapterHtml above).
3406  content.style.fontSize = `${readerFontSize}rem`;
3407  _disposePublishedChapter = renderPublishedChapter(content, normalizeChapterContent(decision.content), {
3408    sanitize: sanitizeChapterHtml,
3409    accountId: _cfg.getCurrentUser()?.id || 'guest',
3410    progressKey: `${_cfg.getCurrentUser()?.id || 'guest'}:${currentBookDetail.book.id}:${chapter.id || chapter.chapter_number}`,
3411    chapterTitle:chapter.title,bookTitle:currentBookDetail.book.title,
3412    socialProgressScope:{readerId:_cfg.getCurrentUser()?.id||'guest',bookId:currentBookDetail.book.id,chapterId:chapter.id||chapter.chapter_number},
3413    resolveMedia: async path => {
3414      if (!isCurrent()) return null;
3415      const {data, error} = await supabase.storage.from(path.startsWith('social-story/')?'social-story-media':'epistolary-media').createSignedUrl(path, 3600);
3416      return isCurrent() && !error ? data?.signedUrl : null;
3417    },
3418  });
3419  content.scrollTop = 0;
3420  window.scrollTo({ top: 0, behavior: 'smooth' });
3421
3422  // Apply username watermark (re-run in case the user logged in/out since last read)
3423  applyReaderWatermark();
3424
3425  // ─── Telemetry + counters fire HERE — past the paywall check, only
3426  // when the reader actually sees content. (Pre-Codex this ran at the
3427  // top of openChapterReader, which farmed read-goal ticks + dwell
3428  // telemetry from users who only saw the lock CTA — Codex P1.)
3429
3430  // Daily-goal: "Read N chapters" tick. Dedupe by chapter id (strip
3431  // the 'sb_' prefix if present) so re-opening the same chapter
3432  // mid-session doesn't farm. Fire-and-forget — the RPC is best-
3433  // effort and never gates the reader render. Mirrors mobile at
3434  // app/(book)/book-reading.jsx:444.
3435  try {
3436    const ckey = String(resolvedChapterId || '').replace(/^sb_/, '');
3437    if (ckey) _cfg.tickGoalUnique('read_chapters', ckey);
3438  } catch {}
3439
3440  // Anti-fraud telemetry — log chapter open (dwell_ms=0, scroll_pct=0).
3441  // The matching close event fires from the "previous chapter" / "next
3442  // chapter" / back-out handlers via flushReadClose with the actual
3443  // dwell + scroll values. Two-event pattern lets Phase 4's detection
3444  // job compute dwell distributions and flag bot-like reads (open →
3445  // immediate close with no scroll).
3446  const realChapterIdForLog = String(resolvedChapterId || '').replace(/^sb_/, '');
3447  if (realChapterIdForLog) {
3448    _readChapterOpenTs = Date.now();
3449    _readChapterOpenId = realChapterIdForLog;
3450    _readChapterOpenBookId = currentBookDetail?.book?.id || null;
3451    _readMaxScrollPct = 0;
3452    _cfg.logRead({
3453      chapterId: realChapterIdForLog,
3454      bookId:    _readChapterOpenBookId,
3455      dwellMs:   0,
3456      scrollPct: 0,
3457    });
3458  }
3459
3460  // Bump the chapter views counter via the canonical RPC. Pre-Codex
3461  // the saveReadingProgress() path wrote directly to `chapter_reads`,
3462  // relying on a trigger to bump chapters.views_count + books.views_count.
3463  // That trigger was dropped in 2026-05-09_simple_views_no_cooldown.sql
3464  // (replaced with the record_chapter_view RPC). Without this call,
3465  // web chapter reads stopped feeding the aggregate counter while
3466  // mobile kept incrementing it — writers' view totals would drift
3467  // mobile-only. (Codex P1.)
3468  try {
3469    supabase.rpc('record_chapter_view', { p_chapter_id: resolvedChapterId });
3470  } catch (e) {
3471    console.warn('[reader] record_chapter_view failed (non-fatal):', e?.message || e);
3472  }
3473
3474  // ── BX-2B scroll-position resume ──────────────────────────────────────────
3475  // Arm the live (debounced) scroll-save for THIS entitled chapter + register
3476  // the window listeners once. Then read the saved book_reads row BEFORE
3477  // saveReadingProgress rewrites chapter/time, so we can (a) restore scroll only
3478  // when reopening the SAME chapter, and (b) tell saveReadingProgress whether to
3479  // reset last_scroll_pct (chapter changed). Logged-out → no read/restore; the
3480  // save itself is gated on a signed-in user + RLS (own row only).
3481  _scrollSaveBookId = currentBookDetail.book.id;
3482  _scrollSaveChapterId = resolvedChapterId;
3483  _ensureReaderScrollListeners();
3484  if (_cfg.getCurrentUser()?.id) {
3485    const savedRead = await _readBookProgressForResume(bookId);
3486    if (!isCurrent()) return;
3487    const sameChapter = !!savedRead && savedRead.last_chapter_id === resolvedChapterId;
3488    if (sameChapter && _clampScrollPct(savedRead.last_scroll_pct) > _SCROLL_RESTORE_THRESHOLD) {
3489      _restoreScrollAfterRender(resolvedChapterId, savedRead.last_scroll_pct, content);
3490    }
3491    saveReadingProgress(currentBookDetail.book.id, resolvedChapterId, chapter.chapter_number, !sameChapter);
3492  } else {
3493    saveReadingProgress(currentBookDetail.book.id, resolvedChapterId, chapter.chapter_number, true);
3494  }
3495
3496  // BX-7B-1: reveal the Comments button + arm lazy-load for THIS entitled chapter. Reached ONLY
3497  // here (decision.kind==='content') — error/paywall/notfound/unavailable all returned earlier,
3498  // so locked/unpublished/private/stale chapters never reveal comments. resolvedChapterId is the
3499  // server-resolved entitled chapter UUID. No chapter_comments fetch happens until the user opens
3500  // the modal. (Comments are commentary, not chapter content — no content fetch, gate untouched.)
3501  _revealReaderComments(resolvedChapterId);
3502}
3503
3504// Normalize chapter content: if HTML-ish, render as-is; if plain text, wrap in <p>
3505function normalizeChapterContent(content) {
3506  if (!content) return '<p><em>(No content)</em></p>';
3507  // Detect common HTML tags to decide
3508  if (/<\/?(p|div|br|h[1-6]|blockquote|ul|ol|li|strong|em|b|i|a|img|span)\b/i.test(content)) {
3509    return content;
3510  }
3511  // Treat as plain text — split by blank lines into paragraphs, preserve single newlines as <br>
3512  return content
3513    .split(/\n\s*\n/)
3514    .filter(p => p.trim())
3515    .map(p => `<p>${escHTML(p).replace(/\n/g, '<br>')}</p>`)
3516    .join('');
3517}
3518
3519async function saveReadingProgress(bookId, chapterId, chapterNumber, resetScroll = false) {
3520  const userId = _cfg.getCurrentUser()?.id;
3521  const identity = _readerIdentity();
3522  const generation = _readerRequestGeneration;
3523  if (!userId) return;
3524  try {
3525    const { data: { user } } = await supabase.auth.getUser();
3526    // Auth lookup can finish after a sign-out, account swap, or chapter change.
3527    // Never attach the previous chapter to the newly returned auth account.
3528    if (user?.id !== userId || identity !== _readerIdentity() || generation !== _readerRequestGeneration) return;
3529    const nowIso = new Date().toISOString();
3530
3531    // 1) book_reads — per-user reading progress (continue-reading rail).
3532    //    Composite PK (user_id, book_id), one row per user per book ever.
3533    const row = {
3534      user_id: userId,
3535      book_id: bookId,
3536      last_chapter_id: chapterId,
3537      last_chapter_number: chapterNumber,
3538      last_read_at: nowIso,
3539    };
3540    // BX-2B: on a chapter change (or a brand-new row), reset last_scroll_pct to
3541    // 0 so a resume of the NEW chapter doesn't inherit the previous chapter's
3542    // offset (mirrors mobile's server-side reset). On the SAME chapter (resume),
3543    // OMIT last_scroll_pct so the upsert doesn't clobber the saved value we're
3544    // about to restore — the debounced scroll listener owns it from here.
3545    if (resetScroll) row.last_scroll_pct = 0;
3546    await supabase.from('book_reads').upsert(row, { onConflict: 'user_id,book_id' });
3547
3548    // (The direct `chapter_reads` insert + 23505-fallback used to live
3549    // here, relying on the old bump_views_on_chapter_read trigger.
3550    // That trigger was dropped in 2026-05-09_simple_views_no_cooldown.sql
3551    // — views are now bumped by `record_chapter_view` RPC, which
3552    // openChapterReader calls after the paywall check. Removing this
3553    // block prevents a double-bump and matches mobile's path.)
3554  } catch (e) { /* ignore */ }
3555}
3556
3557// ── BX-2B scroll-position resume (mobile parity) ─────────────────────────────
3558// The web reader scrolls the WINDOW (chapter HTML flows into #readerContent,
3559// which has no overflow). We persist the reader's vertical position as
3560// book_reads.last_scroll_pct (0–1, DB CHECK-enforced) and restore it when the
3561// user reopens the SAME chapter. Write-safe: book_reads RLS scopes a logged-in
3562// caller to their OWN row (anon blocked), no RPC, no trigger, no aggregates.
3563// Every hook runs ONLY inside the entitled content branch of openChapterReader,
3564// so locked/unentitled chapters never save or restore (and never expose content).
3565const _SCROLL_SAVE_DEBOUNCE_MS = 1500;   // mobile parity (SCROLL_PERSIST_DEBOUNCE_MS)
3566const _SCROLL_RESTORE_THRESHOLD = 0.05;  // mobile parity — below this, stay at top
3567let _scrollSaveTimer = null;
3568let _scrollSaveBookId = null;            // book whose row the live save updates
3569let _scrollSaveChapterId = null;         // chapter currently open (null = save off)
3570let _readerScrollListenersOn = false;
3571let _pendingResume = null;               // { chapterId } guard for the image retry
3572let _lastProgrammaticScrollTs = 0;
3573
3574function _clampScrollPct(n) {
3575  const v = Number(n);
3576  if (!Number.isFinite(v)) return 0;
3577  return Math.max(0, Math.min(1, v));
3578}
3579
3580function _getWindowScrollPct() {
3581  const el = document.scrollingElement || document.documentElement;
3582  const range = el.scrollHeight - window.innerHeight;
3583  if (!(range > 0)) return 0;            // short chapter → no division by zero
3584  return _clampScrollPct((window.scrollY || el.scrollTop || 0) / range);
3585}
3586
3587// Restore window scroll to `pct` of the document range, after layout. One
3588// image-load/layout retry: image-heavy chapters grow the document height as
3589// <img>s load, so the first seek lands short — re-apply once after the images
3590// settle (or a short fallback for cached/no images), then stop.
3591function _restoreScrollAfterRender(chapterId, pct, contentEl) {
3592  const p = _clampScrollPct(pct);
3593  if (!chapterId || p <= _SCROLL_RESTORE_THRESHOLD) return;
3594  _pendingResume = { chapterId };
3595  const apply = () => {
3596    if (!_pendingResume || _pendingResume.chapterId !== chapterId) return;  // user took over
3597    const el = document.scrollingElement || document.documentElement;
3598    const range = el.scrollHeight - window.innerHeight;
3599    if (range > 0) {
3600      _lastProgrammaticScrollTs = Date.now();
3601      window.scrollTo({ top: Math.round(range * p), behavior: 'instant' });
3602    }
3603  };
3604  requestAnimationFrame(apply);
3605  const imgs = contentEl ? Array.from(contentEl.querySelectorAll('img')).filter(i => !i.complete) : [];
3606  const finish = () => { apply(); _pendingResume = null; };
3607  if (imgs.length) {
3608    let done = 0;
3609    const onOne = () => { if (++done >= imgs.length) requestAnimationFrame(finish); };
3610    imgs.forEach(img => { img.addEventListener('load', onOne, { once: true }); img.addEventListener('error', onOne, { once: true }); });
3611    setTimeout(finish, 3000);            // safety cap if an image never resolves
3612  } else {
3613    setTimeout(() => requestAnimationFrame(finish), 300);  // layout-settle fallback
3614  }
3615}
3616
3617function _onReaderScroll() {
3618  if (!_scrollSaveChapterId) return;     // only while a chapter reader is open
3619  // A genuine user scroll (not our programmatic restore) cancels the pending
3620  // resume retry so we never yank the reader back after they've moved.
3621  if (_pendingResume && Date.now() - _lastProgrammaticScrollTs > 200) _pendingResume = null;
3622  const pct = _getWindowScrollPct();
3623  if (pct > _readMaxScrollPct) _readMaxScrollPct = pct;   // also feeds flushReadClose telemetry
3624  if (_scrollSaveTimer) clearTimeout(_scrollSaveTimer);
3625  _scrollSaveTimer = setTimeout(_flushScrollProgressSave, _SCROLL_SAVE_DEBOUNCE_MS);
3626}
3627
3628// Persist the current scroll position to the signed-in user's OWN book_reads
3629// row (RLS scopes to current_profile_id). Snapshots pct synchronously before
3630// any await so a chapter switch / scroll-to-top can't change it mid-flight.
3631// Never blocks reading on failure. Updates only last_scroll_pct + last_read_at.
3632async function _flushScrollProgressSave() {
3633  if (_scrollSaveTimer) { clearTimeout(_scrollSaveTimer); _scrollSaveTimer = null; }
3634  const bookId = _scrollSaveBookId;
3635  const me = _cfg.getCurrentUser();
3636  if (!bookId || !_scrollSaveChapterId || !me?.id) return;  // logged-out → never write
3637  const pct = _clampScrollPct(_getWindowScrollPct());        // snapshot NOW
3638  try {
3639    await supabase.from('book_reads')
3640      .update({ last_scroll_pct: pct, last_read_at: new Date().toISOString() })
3641      .eq('user_id', me.id).eq('book_id', bookId);
3642  } catch (e) { /* never block reading on a failed progress save */ }
3643}
3644
3645async function _readBookProgressForResume(bookId) {
3646  const me = _cfg.getCurrentUser();
3647  if (!me?.id || !bookId) return null;
3648  try {
3649    const { data } = await supabase.from('book_reads')
3650      .select('last_chapter_id, last_scroll_pct')
3651      .eq('user_id', me.id).eq('book_id', bookId).maybeSingle();
3652    return data || null;
3653  } catch { return null; }
3654}
3655
3656function _ensureReaderScrollListeners() {
3657  if (_readerScrollListenersOn) return;
3658  _readerScrollListenersOn = true;
3659  window.addEventListener('scroll', _onReaderScroll, { passive: true });
3660  window.addEventListener('pagehide', _flushScrollProgressSave);
3661  document.addEventListener('visibilitychange', () => {
3662    if (document.visibilityState === 'hidden') _flushScrollProgressSave();
3663  });
3664}
3665
3666async function getReaderWatermarkLabel() {
3667  if (_watermarkLabelCache) return _watermarkLabelCache;
3668  try {
3669    const { data: { user } } = await supabase.auth.getUser();
3670    if (!user) { _watermarkLabelCache = 'Guest'; return _watermarkLabelCache; }
3671    // Try profile.username, then email local part, then user id prefix.
3672    // Use the canonical resolver so migrated users see their real username
3673    // in the watermark, not the redacted "_deleted_xxx" handle that lives
3674    // on their deleted orphan profile (JustAnotherJane 2026-05-30 fix).
3675    const { data: profile } = await resolveProfileForAuthUser(user.id, 'username');
3676    const username = profile?.username
3677      || (user.email ? user.email.split('@')[0] : null)
3678      || (user.id ? user.id.slice(0, 8) : 'User');
3679    _watermarkLabelCache = `${username} · ${(user.id || '').slice(0, 6)}`;
3680  } catch {
3681    _watermarkLabelCache = 'Reader';
3682  }
3683  return _watermarkLabelCache;
3684}
3685
3686async function applyReaderWatermark() {
3687  const el = document.getElementById('readerContent');
3688  if (!el) return;
3689  const label = await getReaderWatermarkLabel();
3690  const isLight = document.body.classList.contains('light');
3691  const fillColor = isLight ? '%237c3aed' : '%23a78bfa'; // %23 = encoded "#"
3692  // Encode for safe data URI use
3693  const safeLabel = String(label).replace(/[<>&'"]/g, c =>
3694    ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&#39;', '"': '&quot;' }[c]));
3695  const svg =
3696    '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200">' +
3697      '<text x="160" y="100" text-anchor="middle"' +
3698        ' transform="rotate(-25 160 100)"' +
3699        ` style="fill:${isLight ? 'var(--accent-strong)' : 'var(--accent-light)'};fill-opacity:0.09"` +
3700        ' font-family="system-ui, -apple-system, sans-serif"' +
3701        ' font-size="13" font-weight="500" letter-spacing="0.04em">' +
3702        safeLabel +
3703      '</text>' +
3704    '</svg>';
3705  const dataUri = `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`;
3706  el.style.setProperty('--reader-watermark-bg', dataUri);
3707}
3708
3709
3710// ─── Stage 8B exports ─────────────────────────────────────────────
3711export {
3712  openBookDetail,
3713  renderBookDetail,
3714  loadBookActionState,
3715  setBookActionActive,
3716  toggleBookLike,
3717  toggleBookBookmark,
3718  openChapterReader,
3719  normalizeChapterContent,
3720  saveReadingProgress,
3721  getReaderWatermarkLabel,
3722  applyReaderWatermark,
3723};
3724
3725// ─── Stage 8A exports ─────────────────────────────────────────────
3726export {
3727  PUBLIC_READER_BOOK_STATUSES,
3728  isReaderEligibleBook,
3729  filterReaderEligibleBooks,
3730  showBook,
3731  loadBooksTab,
3732  _loadForYouTab,
3733  _renderBookSection,
3734  _fetchHiddenGems,
3735  _fetchQuickReads,
3736  _fetchWeeklyFeaturedWithFallback,
3737  _fetchRecommendedForUser,
3738  _loadRankingTab,
3739  _renderRankGenreChips,
3740  _loadRankingForGenre,
3741  _renderRankCard,
3742  _loadDiscoverTab,
3743  _loadDiscoverGenreRow,
3744  _loadCollectionTab,
3745  _loadReadingListTab,
3746  _renderBookCardV2,
3747  _openBookSeeAll,
3748  _loadMoreSeeAllBooks,
3749  _setupSeeAllInfiniteScroll,
3750  applyBookFilter,
3751  searchBooks,
3752  runBookSearch,
3753  renderWriterChannelCard,
3754  _normalizeBookRow,
3755  _normalizeBookRows,
3756  fetchSupabaseBooks,
3757  fetchBooksServerSearch,
3758  renderBookCard,
3759  prettyGenre,
3760  getUserBookTaste,
3761  renderBookChips,
3762  loadBookRecommendations,
3763  renderBookRecsRail,
3764};
3765
3766// ─── Session-state reset (called from app.js sign-in/sign-out paths) ─────
3767// Reaches into module-private state that's user-scoped — the per-tab
3768// "first visit" gates, the user-taste cache, and the personalised recs
3769// cache. Stage 8A pre-Codex left these as `typeof X !== 'undefined'`
3770// guards in app.js, which silently returned `undefined` in an ES module
3771// and never ran the reset. Result: a user signing out then a different
3772// user signing in could briefly see the previous user's reading list
3773// gate + cached taste + cached recs. (Codex P1 catch.)
3774//
3775// `clearPublic: true` ALSO drops the unfiltered book pool — used at
3776// sign-out so we don't keep the previous browsing context in memory.
3777// Sign-in does NOT pass clearPublic because the listing data isn't
3778// user-scoped (everyone sees the same trending/recent set).
3779export function resetBooksSessionState({ clearPublic = false } = {}) {
3780  resetBookReaderSession();
3781  _booksMobileController?.invalidate();
3782  _booksForYouSource = null;
3783  _booksDiscoverSource?.invalidate();
3784  _booksDiscoverSource = null;
3785  _booksRankingSource?.invalidate();
3786  _booksRankingSource = null;
3787  _booksLibrarySource?.invalidate();
3788  _booksLibrarySource = null;
3789  _booksLibraryListsSource?.invalidate();
3790  _booksLibraryListsSource = null;
3791  _booksReadingListSource?.invalidate();
3792  _booksReadingListSource = null;
3793  // Per-tab first-visit gates — forces Reading List (and the rest) to
3794  // re-fetch on the next showBook() / loadBooksTab() call.
3795  _bookTabLoaded.foryou      = false;
3796  _bookTabLoaded.discover    = false;
3797  _bookTabLoaded.ranking     = false;
3798  _bookTabLoaded.readinglist = false;
3799
3800  // User-taste cache (book_reads + book_likes seedlist).
3801  _userBookTasteCache = null;
3802  _userBookTasteAt    = 0;
3803
3804  // Personalised recommendation rail cache.
3805  _bookRecsCache     = null;
3806  _bookRecsTimestamp = 0;
3807
3808  if (clearPublic) {
3809    // Drop the cached public listing too. Mostly cosmetic — the next
3810    // showBook() will repopulate — but keeps "previous user's session"
3811    // out of memory entirely.
3812    allBooksCache = [];
3813    allBooksRaw   = [];
3814  }
3815}
3816
3817// ─── Accessors for module-private state that app.js still touches ─────────
3818// These are the only state vars the search bar handler and (legacy) tab
3819// reader at line ~5760 of app.js still need to read/write. Exporting
3820// getter/setter pairs keeps the source of truth here in books.js while
3821// letting the topbar search input stay in app.js (it's not books-only —
3822// the same input also runs video search and post search).
3823export function getActiveBookTab()           { return _activeBookTab; }
3824export function setActiveBookTab(t)          { _activeBookTab = t; }
3825export function getActiveBookSearchQuery()   { return activeBookSearchQuery; }
3826export function setActiveBookSearchQuery(v)  { activeBookSearchQuery = v; }
3827
3828// ─── wireBooksPage — module-level DOM wiring, called from app.js ──────────
3829// Three event listeners that previously lived as top-level side-effects in
3830// app.js. They mutate module-private state (_activeBookTab, _seeAllObserver),
3831// so they belong here rather than reaching across modules through getters.
3832//
3833// Guarded so re-calling at sign-in (app.js's onSignedIn invokes it again)
3834// doesn't double-bind. Without this guard the See-All grid could stick on
3835// "Loading…" because two delegated click handlers race: the first sets
3836// _seeAllLoading=true, the second increments _seeAllSeq but bails on the
3837// loading check, then the first response gets discarded as stale.
3838// (Codex P1 catch — only matters once auth flips re-call this function.)
3839let _booksPageWired = false;
3840export function wireBooksPage() {
3841  if (_booksPageWired) return;
3842  _booksPageWired = true;
3843  // Tab switching — one delegated listener on the tab bar.
3844  document.getElementById('bookTabs')?.addEventListener('click', (e) => {
3845    const tabBtn = e.target.closest('.book-tab');
3846    if (!tabBtn) return;
3847    const t = tabBtn.dataset.bookTab;
3848    if (!t || t === _activeBookTab) return;
3849    _activeBookTab = t;
3850    document.querySelectorAll('#bookTabs .book-tab').forEach(x => {
3851      const isActive = x === tabBtn;
3852      x.classList.toggle('active', isActive);
3853      x.setAttribute('aria-selected', isActive ? 'true' : 'false');
3854    });
3855    document.querySelectorAll('.book-tab-panel').forEach(p => {
3856      const isActive = p.dataset.bookPanel === t;
3857      p.style.display = isActive ? '' : 'none';
3858      p.classList.toggle('active', isActive);
3859    });
3860    // Close any open See-All sub-view when switching tabs.
3861    const seeAll = document.getElementById('bookSeeAllView');
3862    if (seeAll) seeAll.style.display = 'none';
3863    if (_seeAllObserver) { _seeAllObserver.disconnect(); _seeAllObserver = null; }
3864    loadBooksTab(t);
3865    window.scrollTo({ top: 0, behavior: 'instant' });
3866  });
3867
3868  // "See All" link — event-delegated from the bookPage container so we
3869  // pick up section rows even after Discover/Ranking lazy-render.
3870  document.getElementById('bookPage')?.addEventListener('click', (e) => {
3871    const seeAllBtn = e.target.closest('.book-section-see-all');
3872    if (!seeAllBtn) return;
3873    e.preventDefault();
3874    _openBookSeeAll(seeAllBtn.dataset.seeAll);
3875  });
3876
3877  // See-All back button — restores the active tab panel and tears down
3878  // the infinite-scroll observer for the sub-view.
3879  document.getElementById('btnBookSeeAllBack')?.addEventListener('click', () => {
3880    const seeAll = document.getElementById('bookSeeAllView');
3881    if (seeAll) seeAll.style.display = 'none';
3882    document.querySelectorAll('.book-tab-panel').forEach(p => {
3883      const isActive = p.dataset.bookPanel === _activeBookTab;
3884      p.style.display = isActive ? '' : 'none';
3885      p.classList.toggle('active', isActive);
3886    });
3887    if (_seeAllObserver) { _seeAllObserver.disconnect(); _seeAllObserver = null; }
3888    window.scrollTo({ top: 0, behavior: 'instant' });
3889  });
3890}
3891
3892// ─── flushReadClose — Stage 8B (moved from app.js with its state) ──────────
3893// Emits the matching "close" event for the most recent logRead({open})
3894// fired by openChapterReader. Called from app.js's hideAllMainPages when
3895// the chapter reader is leaving the screen (back-button, sidebar nav,
3896// deep-link to another route), and called from openChapterReader itself
3897// before opening a new chapter so the previous chapter's dwell isn't
3898// lost. All four module-private state vars (_readChapterOpenTs/Id/BookId
3899// + _readMaxScrollPct) get reset here so a re-open starts fresh.
3900export function flushReadClose(completed = null) {
3901  _disposePublishedChapter?.(); _disposePublishedChapter = null;
3902  _readerRequestGeneration += 1;
3903  // BX-2B: persist the closing chapter's scroll position (snapshotted before
3904  // the next chapter resets scroll), then quiet the live save + cancel any
3905  // pending resume until the next chapter arms it.
3906  _flushScrollProgressSave();
3907  _scrollSaveChapterId = null;
3908  _pendingResume = null;
3909  if (!_readChapterOpenTs || !_readChapterOpenId) return;
3910  const dwellMs = Date.now() - _readChapterOpenTs;
3911  _cfg.logRead({
3912    chapterId: _readChapterOpenId,
3913    bookId:    _readChapterOpenBookId,
3914    dwellMs,
3915    scrollPct: _readMaxScrollPct,
3916    completed,
3917  });
3918  _readChapterOpenTs = 0;
3919  _readChapterOpenId = null;
3920  _readChapterOpenBookId = null;
3921  _readMaxScrollPct = 0;
3922}
3923
3924// ─── wireBookReader — Stage 8B module-level DOM wiring ─────────────────────
3925// Five things that previously lived as top-level side-effects in app.js.
3926// They all reach into book-detail / chapter-reader state (currentBookDetail,
3927// currentChapterIndex, readerFontSize, applyReaderWatermark) so they belong
3928// here rather than across modules through accessors. App.js calls this
3929// once at boot, right after wireBooksPage().
3930//
3931//   1. btnBackBooks → showBook (return from detail page to listing)
3932//   2. #sidebarThemeToggle .sidebar-theme-option → re-render watermark on
3933//      theme flip so the muted purple stays legible in both modes
3934//   3. setupReaderAntiCopy IIFE → selectstart / copy / cut / contextmenu /
3935//      dragstart / Cmd-A·C·X blockers on the reader content
3936//   4. btnReaderPrev / btnReaderNext → chapter navigation
3937//   5. btnBackBookDetail → return to detail page from reader
3938//   6. btnReaderFontSmaller / btnReaderFontLarger → font-size adjust + persist
3939//
3940// `?.addEventListener` no-ops on missing elements, so wireBookReader is
3941// safe to call before the full reader DOM exists (it doesn't, but the
3942// guard means a future hot-reload that swaps out elements won't crash).
3943//
3944// Same idempotency guard as wireBooksPage — anti-copy + nav listeners
3945// would double-fire if re-called at sign-in (Codex P1 sibling catch).
3946let _bookReaderWired = false;
3947export function wireBookReader() {
3948  if (_bookReaderWired) return;
3949  _bookReaderWired = true;
3950  // (1) Back to books listing.
3951  document.getElementById('btnBackBooks')?.addEventListener('click', () => showBook());
3952
3953  // (2) Theme-toggle → re-render watermark. The watermark uses different
3954  // fill colours in light vs dark mode (purple muted to ~9% alpha either
3955  // way) — re-rendering after the body class flips keeps the SVG visible.
3956  // Deferred 50ms so the body class swap actually lands first.
3957  document.querySelectorAll('#sidebarThemeToggle .sidebar-theme-option').forEach((btn) => {
3958    btn.addEventListener('click', () => {
3959      setTimeout(() => {
3960        if (chapterReaderPage?.style.display === 'block') applyReaderWatermark();
3961      }, 50);
3962    });
3963  });
3964
3965  // (3) Anti-copy guard on the reader. Discourages casual copy-paste —
3966  // not bulletproof against DevTools/view-source, but blocks selection,
3967  // right-click, Cmd/Ctrl+C/X/A inside the reader content.
3968  (function setupReaderAntiCopy() {
3969    const el = document.getElementById('readerContent');
3970    if (!el) return;
3971
3972    const isReaderVisible = () =>
3973      chapterReaderPage && chapterReaderPage.style.display === 'block';
3974
3975    let antiCopyToastTimer = null;
3976    const showAntiCopyToast = () => {
3977      if (antiCopyToastTimer) return;          // throttle so we don't spam
3978      toast('Copying is disabled to protect the author\'s work', 'error');
3979      antiCopyToastTimer = setTimeout(() => { antiCopyToastTimer = null; }, 1500);
3980    };
3981
3982    // Block selection-start (covers click-and-drag)
3983    el.addEventListener('selectstart', (e) => { e.preventDefault(); return false; });
3984
3985    // Block native copy (Cmd/Ctrl+C, right-click → Copy)
3986    el.addEventListener('copy', (e) => {
3987      e.preventDefault();
3988      e.clipboardData?.setData('text/plain', '');
3989      showAntiCopyToast();
3990      return false;
3991    });
3992
3993    // Block cut + paste too, just in case the reader ever becomes editable
3994    el.addEventListener('cut', (e) => { e.preventDefault(); return false; });
3995
3996    // Block right-click context menu inside the reader
3997    el.addEventListener('contextmenu', (e) => {
3998      e.preventDefault();
3999      showAntiCopyToast();
4000      return false;
4001    });
4002
4003    // Block drag (so users can't drag-out a text fragment)
4004    el.addEventListener('dragstart', (e) => { e.preventDefault(); return false; });
4005
4006    // Block Cmd/Ctrl+A so a user can't quickly select-all then copy
4007    document.addEventListener('keydown', (e) => {
4008      if (!isReaderVisible()) return;
4009      const isMod = e.metaKey || e.ctrlKey;
4010      if (!isMod) return;
4011      const k = e.key.toLowerCase();
4012      if (k === 'a' || k === 'c' || k === 'x') {
4013        // Only block if the focus is in/over the reader content, not in form inputs
4014        const tag = (e.target?.tagName || '').toLowerCase();
4015        if (tag === 'input' || tag === 'textarea' || e.target?.isContentEditable) return;
4016        e.preventDefault();
4017        showAntiCopyToast();
4018      }
4019    });
4020  })();
4021
4022  // (4) Reader prev/next chapter buttons.
4023  document.getElementById('btnReaderPrev')?.addEventListener('click', () => {
4024    if (currentChapterIndex > 0) openChapterReader(currentChapterIndex - 1);
4025  });
4026  document.getElementById('btnReaderNext')?.addEventListener('click', () => {
4027    if (currentBookDetail && currentChapterIndex < currentBookDetail.chapters.length - 1) {
4028      openChapterReader(currentChapterIndex + 1);
4029    }
4030  });
4031
4032  // (5) Back to book detail from reader.
4033  document.getElementById('btnBackBookDetail')?.addEventListener('click', () => {
4034    if (currentBookDetail) openBookDetail(currentBookDetail.book.id);
4035  });
4036
4037  // (6) Font-size adjust + persist. Bounded 0.85–1.6 rem.
4038  document.getElementById('btnReaderFontSmaller')?.addEventListener('click', () => {
4039    readerFontSize = Math.max(0.85, readerFontSize - 0.05);
4040    localStorage.setItem('selebox_reader_font', readerFontSize);
4041    document.getElementById('readerContent').style.fontSize = `${readerFontSize}rem`;
4042  });
4043  document.getElementById('btnReaderFontLarger')?.addEventListener('click', () => {
4044    readerFontSize = Math.min(1.6, readerFontSize + 0.05);
4045    localStorage.setItem('selebox_reader_font', readerFontSize);
4046    document.getElementById('readerContent').style.fontSize = `${readerFontSize}rem`;
4047  });
4048
4049  // BX-5: reader chapter TOC — opens a modal listing currentBookDetail.chapters;
4050  // each row jumps via the existing gated openChapterReader (no content fetch here).
4051  document.getElementById('btnReaderChapters')?.addEventListener('click', () => _openReaderChapters());
4052  document.getElementById('readerChaptersClose')?.addEventListener('click', () => _closeReaderChapters());
4053  document.getElementById('readerChaptersModal')?.addEventListener('click', (e) => {
4054    if (e.target.id === 'readerChaptersModal') _closeReaderChapters();   // backdrop
4055  });
4056  document.addEventListener('keydown', (e) => {
4057    if (e.key !== 'Escape') return;
4058    const m = document.getElementById('readerChaptersModal');
4059    if (m && m.classList.contains('open')) _closeReaderChapters();
4060  });
4061
4062  // BX-6A: reader settings (theme + line-spacing). Opens a modal whose option buttons write
4063  // localStorage and re-apply data-attrs to #chapterReaderPage. Pure presentation — A−/A+,
4064  // the TOC modal, scroll resume, and the entitlement/content path are all untouched.
4065  document.getElementById('btnReaderSettings')?.addEventListener('click', () => _openReaderSettings());
4066  document.getElementById('readerSettingsClose')?.addEventListener('click', () => _closeReaderSettings());
4067  document.getElementById('readerSettingsModal')?.addEventListener('click', (e) => {
4068    if (e.target.id === 'readerSettingsModal') _closeReaderSettings();   // backdrop
4069  });
4070  document.querySelectorAll('#readerSettingsModal [data-set-theme]').forEach((b) => {
4071    b.addEventListener('click', () => _setReaderTheme(b.dataset.setTheme));
4072  });
4073  document.querySelectorAll('#readerSettingsModal [data-set-line]').forEach((b) => {
4074    b.addEventListener('click', () => _setReaderLine(b.dataset.setLine));
4075  });
4076  // BX-6B: font family / reader width / paragraph spacing toggles.
4077  document.querySelectorAll('#readerSettingsModal [data-set-family]').forEach((b) => {
4078    b.addEventListener('click', () => _setReaderFamily(b.dataset.setFamily));
4079  });
4080  document.querySelectorAll('#readerSettingsModal [data-set-width]').forEach((b) => {
4081    b.addEventListener('click', () => _setReaderWidth(b.dataset.setWidth));
4082  });
4083  document.querySelectorAll('#readerSettingsModal [data-set-paragraph]').forEach((b) => {
4084    b.addEventListener('click', () => _setReaderParagraph(b.dataset.setParagraph));
4085  });
4086  document.addEventListener('keydown', (e) => {
4087    if (e.key !== 'Escape') return;
4088    const m = document.getElementById('readerSettingsModal');
4089    if (m && m.classList.contains('open')) _closeReaderSettings();
4090  });
4091  // Apply persisted reader settings once at init so the first reader open is already themed.
4092  _applyReaderSettings();
4093
4094  // BX-7B-1: chapter comments (read-only). The button is revealed only after entitled content
4095  // renders (see openChapterReader); opening lazy-loads the current entitled chapter's top-level
4096  // comments. No writes/composer/replies. Distinct modal id; per-modal Esc + backdrop close.
4097  document.getElementById('btnReaderComments')?.addEventListener('click', () => _openReaderComments());
4098  document.getElementById('readerCommentsClose')?.addEventListener('click', () => _closeReaderComments());
4099  document.getElementById('readerCommentsModal')?.addEventListener('click', (e) => {
4100    if (e.target.id === 'readerCommentsModal') _closeReaderComments();   // backdrop
4101  });
4102  document.addEventListener('keydown', (e) => {
4103    if (e.key !== 'Escape') return;
4104    const m = document.getElementById('readerCommentsModal');
4105    if (m && m.classList.contains('open')) _closeReaderComments();
4106  });
4107  // Delegate discussion actions; reply queries stay scoped to the entitled chapter.
4108  document.getElementById('readerCommentForm')?.addEventListener('submit', _sendChapterComment);
4109  document.getElementById('readerCommentInput')?.addEventListener('input', _syncCommentComposer);
4110  document.getElementById('readerCommentCancelReply')?.addEventListener('click', () => { if (_commentPending) return; _commentReply = null; _syncCommentComposer(); });
4111  document.getElementById('readerCommentSignIn')?.addEventListener('click', () => { _closeReaderComments(); _cfg.showAuth(); });
4112  document.getElementById('readerCommentsList')?.addEventListener('click', (e) => {
4113    if (e.target.closest('[data-comments-retry]')) { _loadChapterComments(_readerCommentsChapterId); return; }
4114    const reply = e.target.closest('[data-comment-reply]');
4115    if (reply) {
4116      if (!_cfg.getCurrentUser()?.id) { _closeReaderComments(); _cfg.showAuth(); return; }
4117      if (_commentPending) return;
4118      _commentReply = { id: reply.dataset.commentReply, name: reply.dataset.commentAuthor };
4119      _syncCommentComposer(); document.getElementById('readerCommentInput').focus(); return;
4120    }
4121    const toggle = e.target.closest('[data-reply-toggle]');
4122    if (!toggle) return;
4123    const container = toggle.parentElement?.querySelector('.comment-replies');
4124    _loadCommentReplies(toggle.dataset.parent, toggle, container);
4125  });
4126}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.