|
| 1 | +import { SafeStorage } from './storage.js'; |
| 2 | + |
| 3 | +/** |
| 4 | + * ============================================================================== |
| 5 | + * BiblioDrift Core Logic - Main Application Entry Point |
| 6 | + * ============================================================================== |
| 7 | + * |
| 8 | + * Overview: |
| 9 | + * --------- |
| 10 | + * This file serves as the primary orchestrator for the BiblioDrift application. |
| 11 | + * It ties together the DOM manipulation, state management, 3D rendering interactions, |
| 12 | + * and API communications (both Google Books API and our custom Python backend). |
| 13 | + * |
| 14 | + * Key Components: |
| 15 | + * --------------- |
| 16 | + * 1. SafeStorage: |
| 17 | + * A robust wrapper around `localStorage` with an `IndexedDB` fallback mechanism. |
| 18 | + * This component is critical for offline-first capabilities and prevents the |
| 19 | + * entire app from crashing when iOS/Safari or restrictive browser quotas prevent |
| 20 | + * standard `localStorage` operations. |
| 21 | + * - Automatically handles QuotaExceeded exceptions. |
| 22 | + * - Provides asynchronous data restoration algorithms. |
| 23 | + * - Integrates closely with the LibraryManager to store thousands of books safely. |
| 24 | + * |
| 25 | + * 2. LibraryManager: |
| 26 | + * The central state machine over the user's book collection. |
| 27 | + * - Shelf Types: Manages three distinctive shelves: 'want', 'current', 'finished'. |
| 28 | + * - Concurrency Control: Handles race conditions when syncing local states with |
| 29 | + * the backend utilizing optimistic locking techniques. |
| 30 | + * - Merging Strategy: In the event of a conflict between the client data and |
| 31 | + * server data, it attempts a non-destructive merge, retaining the state with |
| 32 | + * the highest integer version map. |
| 33 | + * |
| 34 | + * 3. BookRenderer: |
| 35 | + * An interface bridge to the DOM. Handles instantiation of HTML templates for |
| 36 | + * individual 3D book instances, binding their unique event listeners, and |
| 37 | + * applying their generated CSS styles and thematic properties. |
| 38 | + * It integrates directly with `LibraryManager` to reflect real-time progress updates. |
| 39 | + * |
| 40 | + * 4. ThemeManager: |
| 41 | + * Observes User Preferences and seamlessly toggles the UI's color palette between |
| 42 | + * predefined themes (e.g., dark mode and light mode, wood mode), persisting |
| 43 | + * these preferences to SafeStorage for a seamless experience across reloads. |
| 44 | + * |
| 45 | + * API Architecture Details: |
| 46 | + * ------------------------- |
| 47 | + * - Google Books API: Facilitates the search and retrieval of rich book metadata |
| 48 | + * including volume summaries, author info, and high-quality thumbnail images. |
| 49 | + * - Local Proxy/Backend: Certain complex interactions such as Machine Learning |
| 50 | + * sentiment analysis (fetchAIVibe) are offloaded to `MOOD_API_BASE` to bypass |
| 51 | + * client-side compute limitations and securely handle secret API keys. |
| 52 | + * |
| 53 | + * Security & Data Integrity Considerations: |
| 54 | + * ----------------------------------------- |
| 55 | + * - Data Sanitization: All text rendered from external APIs is strictly passed |
| 56 | + * through the `escapeHTML` utility safely converting brackets to entities to |
| 57 | + * prevent XSS (Cross-Site Scripting) vectors. |
| 58 | + * - CSRF Protection: Interacts closely with the server-supplied `csrf_access_token` |
| 59 | + * to securely validate state-mutating requests (POST, PUT, DELETE) preventing |
| 60 | + * Cross Site Request Forgery attacks against logged-in users. |
| 61 | + * |
| 62 | + * Coding Standards and Development Guidelines: |
| 63 | + * -------------------------------------------- |
| 64 | + * 1. Offline-First Philosophy: Ensure that actions (add, remove, update) are |
| 65 | + * optimistically applied to local state before waiting for server resolution. |
| 66 | + * 2. Safe Storage Wrapper: Always use `SafeStorage.set()` instead of native |
| 67 | + * `localStorage.setItem()`. |
| 68 | + * 3. Centralized Styling: For broad CSS manipulations, modify standard tokens in |
| 69 | + * `index.css` rather than directly overriding inline styles to maintain a |
| 70 | + * dynamic and cohesive theme strategy. |
| 71 | + * |
| 72 | + * File Structure: |
| 73 | + * --------------- |
| 74 | + * - [000-100]: Initialization and Utility Wrappers |
| 75 | + * - [100-300]: SafeStorage Implementation |
| 76 | + * - [300-800]: BookRenderer Class and 3D interactions |
| 77 | + * - [800-1300]: LibraryManager state machine and synchronization |
| 78 | + * - [1300+]: UI Controllers, Events, and Application Bootstrap |
| 79 | + * ============================================================================== |
| 80 | + */ |
| 81 | + |
| 82 | +// API_BASE and MOOD_API_BASE are declared globally in config.js (loaded first). |
| 83 | +// Do NOT re-declare them here — use the globals from config.js directly. |
| 84 | +const IS_DEV = typeof window !== 'undefined' && ['localhost', '127.0.0.1'].includes(window.location.hostname); |
| 85 | +const moodAnalysisCache = new Map(); |
| 86 | + |
| 87 | +export const delay = (ms) => new Promise((res) => setTimeout(res, ms)); |
| 88 | + |
| 89 | +let GOOGLE_API_KEY = ''; |
| 90 | + |
| 91 | +/** |
| 92 | + * Utility to extract a cookie value by name. |
| 93 | + */ |
| 94 | +export function getCookie(name) { |
| 95 | + const value = `; ${document.cookie}`; |
| 96 | + const parts = value.split(`; ${name}=`); |
| 97 | + if (parts.length === 2) return parts.pop().split(';').shift(); |
| 98 | + return null; |
| 99 | +} |
| 100 | + |
| 101 | +export async function loadConfig() { |
| 102 | + try { |
| 103 | + const res = await fetch(`${MOOD_API_BASE}/config`, { credentials: 'include' }); |
| 104 | + if (res.ok) { |
| 105 | + const data = await res.json(); |
| 106 | + GOOGLE_API_KEY = data.google_books_key || ''; |
| 107 | + if (window.GoogleBooksClient) { |
| 108 | + window.GoogleBooksClient.setKeys([ |
| 109 | + data.google_books_key, |
| 110 | + data.google_books_key_secondary, |
| 111 | + ]); |
| 112 | + } |
| 113 | + if (IS_DEV) { |
| 114 | + console.log('Config loaded'); |
| 115 | + } |
| 116 | + } |
| 117 | + } catch (e) { |
| 118 | + console.warn('Failed to load backend config', e); |
| 119 | + } |
| 120 | +} |
| 121 | + |
| 122 | +export const CollectionAPI = { |
| 123 | + getHeaders() { |
| 124 | + const headers = { 'Content-Type': 'application/json' }; |
| 125 | + const csrf = getCookie('csrf_access_token'); |
| 126 | + if (csrf) { |
| 127 | + headers['X-CSRF-TOKEN'] = csrf; |
| 128 | + } |
| 129 | + return headers; |
| 130 | + }, |
| 131 | + async createCollection(userId, name, description = '', isPublic = false) { |
| 132 | + const res = await fetch(`${MOOD_API_BASE}/api/v1/collections`, { |
| 133 | + method: 'POST', |
| 134 | + headers: this.getHeaders(), |
| 135 | + credentials: 'include', |
| 136 | + body: JSON.stringify({ user_id: parseInt(userId), name, description, is_public: isPublic }) |
| 137 | + }); |
| 138 | + if (!res.ok) { |
| 139 | + const errData = await res.json().catch(() => ({})); |
| 140 | + throw new Error(errData.error || `HTTP ${res.status}`); |
| 141 | + } |
| 142 | + return await res.json(); |
| 143 | + }, |
| 144 | + async getCollections(userId) { |
| 145 | + const res = await fetch(`${MOOD_API_BASE}/api/v1/collections?user_id=${userId}`, { |
| 146 | + method: 'GET', |
| 147 | + headers: this.getHeaders(), |
| 148 | + credentials: 'include' |
| 149 | + }); |
| 150 | + if (!res.ok) { |
| 151 | + const errData = await res.json().catch(() => ({})); |
| 152 | + throw new Error(errData.error || `HTTP ${res.status}`); |
| 153 | + } |
| 154 | + const data = await res.json(); |
| 155 | + return data.collections || []; |
| 156 | + }, |
| 157 | + async getCollection(id) { |
| 158 | + const res = await fetch(`${MOOD_API_BASE}/api/v1/collections/${id}`, { |
| 159 | + method: 'GET', |
| 160 | + headers: this.getHeaders(), |
| 161 | + credentials: 'include' |
| 162 | + }); |
| 163 | + if (!res.ok) { |
| 164 | + const errData = await res.json().catch(() => ({})); |
| 165 | + throw new Error(errData.error || `HTTP ${res.status}`); |
| 166 | + } |
| 167 | + const data = await res.json(); |
| 168 | + return data.collection; |
| 169 | + }, |
| 170 | + async updateCollection(id, name, description, isPublic) { |
| 171 | + const payload = {}; |
| 172 | + if (name !== undefined) payload.name = name; |
| 173 | + if (description !== undefined) payload.description = description; |
| 174 | + if (isPublic !== undefined) payload.is_public = isPublic; |
| 175 | + |
| 176 | + const res = await fetch(`${MOOD_API_BASE}/api/v1/collections/${id}`, { |
| 177 | + method: 'PUT', |
| 178 | + headers: this.getHeaders(), |
| 179 | + credentials: 'include', |
| 180 | + body: JSON.stringify(payload) |
| 181 | + }); |
| 182 | + if (!res.ok) { |
| 183 | + const errData = await res.json().catch(() => ({})); |
| 184 | + throw new Error(errData.error || `HTTP ${res.status}`); |
| 185 | + } |
| 186 | + return await res.json(); |
| 187 | + }, |
| 188 | + async deleteCollection(id) { |
| 189 | + const res = await fetch(`${MOOD_API_BASE}/api/v1/collections/${id}`, { |
| 190 | + method: 'DELETE', |
| 191 | + headers: this.getHeaders(), |
| 192 | + credentials: 'include' |
| 193 | + }); |
| 194 | + if (!res.ok) { |
| 195 | + const errData = await res.json().catch(() => ({})); |
| 196 | + throw new Error(errData.error || `HTTP ${res.status}`); |
| 197 | + } |
| 198 | + return await res.json(); |
| 199 | + }, |
| 200 | + async addBookToCollection(collectionId, userId, bookId, title, authors = '', thumbnail = '') { |
| 201 | + const res = await fetch(`${MOOD_API_BASE}/api/v1/collections/${collectionId}/books`, { |
| 202 | + method: 'POST', |
| 203 | + headers: this.getHeaders(), |
| 204 | + credentials: 'include', |
| 205 | + body: JSON.stringify({ |
| 206 | + user_id: parseInt(userId), |
| 207 | + google_books_id: bookId, |
| 208 | + title: title, |
| 209 | + authors: Array.isArray(authors) ? authors.join(', ') : authors, |
| 210 | + thumbnail: thumbnail |
| 211 | + }) |
| 212 | + }); |
| 213 | + if (!res.ok) { |
| 214 | + const errData = await res.json().catch(() => ({})); |
| 215 | + throw new Error(errData.error || `HTTP ${res.status}`); |
| 216 | + } |
| 217 | + return await res.json(); |
| 218 | + }, |
| 219 | + async removeBookFromCollection(collectionId, bookId) { |
| 220 | + const res = await fetch(`${MOOD_API_BASE}/api/v1/collections/${collectionId}/books/${bookId}`, { |
| 221 | + method: 'DELETE', |
| 222 | + headers: this.getHeaders(), |
| 223 | + credentials: 'include' |
| 224 | + }); |
| 225 | + if (!res.ok) { |
| 226 | + const errData = await res.json().catch(() => ({})); |
| 227 | + throw new Error(errData.error || `HTTP ${res.status}`); |
| 228 | + } |
| 229 | + return await res.json(); |
| 230 | + } |
| 231 | +}; |
| 232 | + |
| 233 | + |
| 234 | +// Example click handler for your custom "Save for Offline" icon |
| 235 | +export async function handleDownloadToggle(bookCard, bookData) { |
| 236 | + const isAlreadyDownloaded = await window.db.downloadedBooks.get(bookData.id); |
| 237 | + |
| 238 | + if (isAlreadyDownloaded) { |
| 239 | + const success = await window.removeOfflineBook(bookData.id); |
| 240 | + if (success) bookCard.classList.remove('is-downloaded'); |
| 241 | + } else { |
| 242 | + const success = await window.saveBookOffline(bookData); |
| 243 | + if (success) bookCard.classList.add('is-downloaded'); |
| 244 | + } |
| 245 | +} |
| 246 | +// Toast Notification Helper |
| 247 | +export function showToast(message, type = 'info') { |
| 248 | + const toast = document.createElement('div'); |
| 249 | + toast.className = `toast-notification ${type}`; |
| 250 | + toast.innerHTML = ` |
| 251 | + <i class="fa-solid ${type === 'error' ? 'fa-circle-exclamation' : 'fa-info-circle'}"></i> |
| 252 | + <span>${message}</span> |
| 253 | + `; |
| 254 | + document.body.appendChild(toast); |
| 255 | + setTimeout(() => { |
| 256 | + toast.style.animation = 'fadeOut 0.3s ease-in forwards'; |
| 257 | + setTimeout(() => toast.remove(), 300); |
| 258 | + }, 3000); |
| 259 | +} |
| 260 | + |
| 261 | +export function clearStoredAuthState() { |
| 262 | + SafeStorage.remove('bibliodrift_user'); |
| 263 | + SafeStorage.remove('bibliodrift_token'); |
| 264 | + SafeStorage.remove('isLoggedIn'); |
| 265 | + authSessionPromise = null; |
| 266 | +} |
| 267 | + |
| 268 | +export function parseStoredUser() { |
| 269 | + const userStr = SafeStorage.get('bibliodrift_user'); |
| 270 | + if (!userStr) return null; |
| 271 | + |
| 272 | + try { |
| 273 | + return JSON.parse(userStr); |
| 274 | + } catch (error) { |
| 275 | + return null; |
| 276 | + } |
| 277 | +} |
| 278 | + |
| 279 | +export function renderAuthNavigation(authLink, tooltip, isAuthenticated) { |
| 280 | + if (!authLink) return; |
| 281 | + |
| 282 | + if (isAuthenticated) { |
| 283 | + authLink.innerHTML = '<i class="fa-solid fa-user"></i> Profile'; |
| 284 | + authLink.href = 'profile.html'; |
| 285 | + authLink.classList.remove('active'); |
| 286 | + authLink.setAttribute('aria-label', 'View profile'); |
| 287 | + if (tooltip) tooltip.innerHTML = '<i class="fa-solid fa-id-card"></i> View Profile'; |
| 288 | + return; |
| 289 | + } |
| 290 | + |
| 291 | + authLink.textContent = 'Sign In'; |
| 292 | + authLink.href = 'auth.html'; |
| 293 | + if (tooltip) tooltip.innerHTML = '<i class="fa-solid fa-key"></i> Access account'; |
| 294 | +} |
| 295 | + |
| 296 | +let authSessionPromise = null; |
| 297 | + |
| 298 | +export async function verifyStoredAuthSession() { |
| 299 | + if (authSessionPromise) { |
| 300 | + return authSessionPromise; |
| 301 | + } |
| 302 | + |
| 303 | + authSessionPromise = (async () => { |
| 304 | + const token = SafeStorage.get('bibliodrift_token'); |
| 305 | + const storedUser = parseStoredUser(); |
| 306 | + const thinksLoggedIn = SafeStorage.get('isLoggedIn') === 'true'; |
| 307 | + |
| 308 | + if (token === 'demo-token-12345') { |
| 309 | + return storedUser; |
| 310 | + } |
| 311 | + |
| 312 | + // Real logins use HttpOnly JWT cookies (see backend JWT_TOKEN_LOCATION). Optional CSRF cookie is readable by JS. |
| 313 | + const shouldProbe = thinksLoggedIn || storedUser || getCookie('csrf_access_token'); |
| 314 | + if (!shouldProbe) { |
| 315 | + return null; |
| 316 | + } |
| 317 | + |
| 318 | + try { |
| 319 | + const headers = { |
| 320 | + ...(token ? { 'Authorization': `Bearer ${token}` } : {}) |
| 321 | + }; |
| 322 | + const csrf = getCookie('csrf_access_token'); |
| 323 | + if (csrf) { |
| 324 | + headers['X-CSRF-TOKEN'] = csrf; |
| 325 | + } |
| 326 | + |
| 327 | + const response = await fetch(`${MOOD_API_BASE}/auth/verify`, { |
| 328 | + credentials: 'include', |
| 329 | + headers, |
| 330 | + method: 'GET', |
| 331 | + credentials: 'include', |
| 332 | + }); |
| 333 | + |
| 334 | + if (response.ok) { |
| 335 | + const data = await response.json(); |
| 336 | + const verifiedUser = data.user || storedUser; |
| 337 | + if (verifiedUser) { |
| 338 | + SafeStorage.set('bibliodrift_user', JSON.stringify(verifiedUser)); |
| 339 | + } |
| 340 | + SafeStorage.set('isLoggedIn', 'true'); |
| 341 | + return verifiedUser || null; |
| 342 | + } |
| 343 | + |
| 344 | + if (response.status === 401 || response.status === 422) { |
| 345 | + clearStoredAuthState(); |
| 346 | + } |
| 347 | + return null; |
| 348 | + } catch (error) { |
| 349 | + console.warn('Auth verification failed; using cached session state if available.', error); |
| 350 | + return storedUser; |
| 351 | + } |
| 352 | + })(); |
| 353 | + |
| 354 | + return authSessionPromise; |
| 355 | +} |
| 356 | + |
| 357 | + |
| 358 | + |
| 359 | + |
| 360 | +/** |
| 361 | + * Robust Wrapper for Storage (LocalStorage + IndexedDB Fallback) |
| 362 | + * Prevents application data loss and handles browser storage wipes/quotas. |
| 363 | + */ |
0 commit comments