Skip to content

Commit 87c59d1

Browse files
committed
refactor(frontend): migrate app.js and library-3d.js to ES6 modules (#868)
1 parent 70f31ed commit 87c59d1

23 files changed

Lines changed: 6195 additions & 6137 deletions

‎frontend/js/app.js‎

Lines changed: 13 additions & 2843 deletions
Large diffs are not rendered by default.

‎frontend/js/app/api.js‎

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
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

Comments
 (0)