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="book-cover-placeholder">${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, '"').replace(/'/g, '''); 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 ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' }[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.