-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathscript.js
More file actions
475 lines (420 loc) · 18.1 KB
/
Copy pathscript.js
File metadata and controls
475 lines (420 loc) · 18.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
// Book Gallery JavaScript
class BookGallery {
constructor() {
this.books = [];
this.filteredBooks = [];
this.filters = {
tags: [],
languages: [],
status: 'read', // Default to showing read books
sortBy: 'dateRead', // Default to sorting via date read
searchTerm: ''
};
this.init();
}
init() {
/*
* Load the data before building filters or cards. If books.js is missing
* or malformed, stop here and show a useful error. Continuing with fake
* sample data would make the page look successful while displaying the
* wrong library and incorrect statistics.
*/
const booksLoaded = this.loadBooks();
if (!booksLoaded) {
this.showBookDataError();
this.updateStats();
return;
}
this.setupEventListeners();
this.populateFilters();
this.applyFilters();
this.updateStats();
}
/**
* Copy the library from books.js into this gallery.
*
* books.js runs before this file and creates `window.BOOKS_DATA`. Reading a
* value that is already in memory works on GitHub Pages, a local web server,
* and a directly opened index.html file. There is no network request to fail.
*
* @returns {boolean} true when a valid books array was found.
*/
loadBooks() {
const data = window.BOOKS_DATA;
if (!data || !Array.isArray(data.books)) {
console.error('Book data could not be loaded from books.js.');
this.books = [];
this.filteredBooks = [];
return false;
}
this.books = data.books;
this.filteredBooks = [...this.books];
return true;
}
/**
* Show an honest, actionable message when the data file cannot be loaded.
* This replaces the old six-book demo fallback, which hid the real problem.
*/
showBookDataError() {
const noResults = document.getElementById('noResults');
noResults.style.display = 'block';
noResults.querySelector('h3').textContent = 'Could not load book data';
noResults.querySelector('p').textContent = 'Make sure books.js is beside index.html, then refresh the page.';
}
setupEventListeners() {
// Checkbox filters
document.addEventListener('change', (e) => {
if (e.target.classList.contains('filter-checkbox')) {
this.updateFilters();
}
if (e.target.classList.contains('sort-radio')) {
this.filters.sortBy = e.target.value;
this.applyFilters();
}
});
// Status buttons
document.querySelectorAll('.status-btn').forEach(button => {
button.addEventListener('click', (clickEvent) => {
/*
* `target` and `currentTarget` sound similar, but they mean
* different things:
*
* - clickEvent.target is the exact element that was clicked.
* That could be the small icon inside this button.
* - clickEvent.currentTarget is the element whose click handler
* is currently running. Here, that is always the status button.
*
* We need the button because the button owns `data-status` and
* the active styling. Passing the whole button also means the
* next method does not need to rely on the browser's unreliable
* global `event` variable.
*/
const clickedButton = clickEvent.currentTarget;
this.handleStatusChange(clickedButton);
});
});
// Search bar
document.getElementById('search').addEventListener('input', (e) => {
// Store a cleaned-up version of what the visitor typed.
//
// Search should not care about capital letters, so "Tolstoy" and
// "tolstoy" must behave in exactly the same way. Trimming also
// prevents an accidental space at either end from hiding a match.
this.filters.searchTerm = this.normalizeTextForSearch(e.target.value).trim();
this.applyFilters();
});
// Clear filters button
document.getElementById('clearFilters').addEventListener('click', () => {
this.clearAllFilters();
});
/*
* On phones, keep the library statistics visible but let visitors show
* or hide the search/filter controls. The `open` class is only used by
* the mobile CSS; desktop controls remain visible regardless.
*
* aria-expanded mirrors the visual state for screen readers. Keeping it
* here, beside the class change, prevents the two states drifting apart.
*/
document.getElementById('sidebarToggle').addEventListener('click', (event) => {
const sidebarControls = document.getElementById('sidebarControls');
const isOpen = sidebarControls.classList.toggle('open');
event.currentTarget.setAttribute('aria-expanded', String(isOpen));
});
}
populateFilters() {
const tags = new Set();
const languages = new Set();
this.books.forEach(book => {
book.tags.forEach(tag => tags.add(tag));
languages.add(book.language);
});
// Populate tag checkboxes
const tagFilters = document.getElementById('tagFilters');
tagFilters.className = 'checkbox-group tags';
tags.forEach(tag => {
const label = document.createElement('label');
label.className = 'checkbox-label';
label.innerHTML = `
<input type="checkbox" value="${tag}" class="filter-checkbox">
<span class="checkmark"></span>
${tag}
`;
tagFilters.appendChild(label);
});
// Populate language checkboxes
const languageFilters = document.getElementById('languageFilters');
languages.forEach(lang => {
const label = document.createElement('label');
label.className = 'checkbox-label';
label.innerHTML = `
<input type="checkbox" value="${lang}" class="filter-checkbox">
<span class="checkmark"></span>
${this.getLanguageName(lang)}
`;
languageFilters.appendChild(label);
});
}
getLanguageName(code) {
const languages = {
'SA': 'Sanskrit',
'EN': 'English',
'BN': 'Bengali',
'ES': 'Spanish',
'DE': 'German',
'HI': 'Hindi',
};
return languages[code] || code;
}
/**
* Convert a value from books.js into text that is safe to search.
*
* Most titles and authors are normal strings. However, an unfinished book
* entry may temporarily contain `null` or may omit a field while it is being
* edited. JavaScript cannot call `.toLowerCase()` on `null`, so doing that
* directly would stop the entire search feature with an error.
*
* Returning an empty string for a missing value is the least surprising
* behaviour: that missing field simply does not match the search, while the
* rest of the library continues to work normally.
*/
normalizeTextForSearch(value) {
if (typeof value !== 'string') {
return '';
}
return value.toLocaleLowerCase();
}
getFormatIcon(format) {
const icons = {
'paper': 'fas fa-book-open',
'ebook': 'fas fa-tablet-alt',
'audio': 'fas fa-headphones'
};
return icons[format] || '';
}
updateFilters() {
// Get all checked checkboxes
const checkedTags = Array.from(document.querySelectorAll('#tagFilters .filter-checkbox:checked')).map(cb => cb.value);
const checkedLanguages = Array.from(document.querySelectorAll('#languageFilters .filter-checkbox:checked')).map(cb => cb.value);
this.filters.tags = checkedTags;
this.filters.languages = checkedLanguages;
this.applyFilters();
}
/**
* Select one reading-status button and display books with that status.
*
* The clicked button is passed in directly by the event listener above.
* Keeping all status-changing work in this one method makes the behaviour
* easy to find and change later.
*/
handleStatusChange(clickedButton) {
// The status is stored in HTML, for example: data-status="reading".
const status = clickedButton.dataset.status;
// This guard protects the page if somebody later adds a status button
// but forgets its data-status attribute. Without it, the gallery would
// quietly filter out every book and misleadingly show zero results.
if (!status) {
console.error('A status button is missing its data-status attribute.');
return;
}
// First remove the yellow "selected" style from every status button.
document.querySelectorAll('.status-btn').forEach(button => {
button.classList.remove('active');
});
// Then apply that style only to the actual button that was clicked.
clickedButton.classList.add('active');
// Finally save the selected status and redraw the matching books.
this.filters.status = status;
this.applyFilters();
}
clearAllFilters() {
// Uncheck all checkboxes
document.querySelectorAll('.filter-checkbox').forEach(cb => cb.checked = false);
// Reset filters
this.filters.tags = [];
this.filters.languages = [];
this.filters.status = 'read'; // Reset to default
this.filters.sortBy = 'dateRead';
this.filters.searchTerm = '';
// Clear search bar
document.getElementById('search').value = '';
// Reset active button
document.querySelectorAll('.status-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector('[data-status="read"]').classList.add('active');
// Reset sort radio
document.querySelector('input[name="sort"][value="dateRead"]').checked = true;
this.applyFilters();
}
applyFilters() {
this.filteredBooks = this.books.filter(book => {
// Search filter
if (this.filters.searchTerm) {
const searchTerm = this.filters.searchTerm;
// Always normalize data from books.js before searching it.
// This is especially important for older or incomplete records,
// where an author can be null. A missing title or author should
// never be able to crash search for every other book.
const searchableTitle = this.normalizeTextForSearch(book.title);
const searchableAuthor = this.normalizeTextForSearch(book.author);
const titleMatch = searchableTitle.includes(searchTerm);
const authorMatch = searchableAuthor.includes(searchTerm);
if (!titleMatch && !authorMatch) return false;
}
// Tag filter (union - OR logic)
if (this.filters.tags.length > 0) {
const hasMatchingTag = this.filters.tags.some(filterTag =>
book.tags.includes(filterTag)
);
if (!hasMatchingTag) return false;
}
// Language filter (union - OR logic)
if (this.filters.languages.length > 0) {
if (!this.filters.languages.includes(book.language)) {
return false;
}
}
// Status filter
if (this.filters.status !== 'all' && book.status !== this.filters.status) {
return false;
}
return true;
});
this.applySorting();
this.renderBooks();
this.updateStats();
}
applySorting() {
if (this.filters.sortBy === 'dateRead') {
this.filteredBooks.sort((a, b) => {
if (a.dateRead && b.dateRead) {
return new Date(b.dateRead) - new Date(a.dateRead);
}
return a.dateRead ? -1 : 1;
});
} else if (this.filters.sortBy === 'rating') {
this.filteredBooks.sort((a, b) => (b.rating || 0) - (a.rating || 0));
}
}
renderBooks() {
const grid = document.getElementById('booksGrid');
const noResults = document.getElementById('noResults');
if (this.filteredBooks.length === 0) {
grid.innerHTML = '';
noResults.style.display = 'block';
return;
}
noResults.style.display = 'none';
grid.innerHTML = this.filteredBooks.map(book => this.createBookCard(book)).join('');
}
createBookCard(book) {
/*
* Show the exact numeric rating instead of repeating star characters.
* String.repeat() accepts only a whole number, so a rating such as 4.5
* was silently shortened to four stars. One decorative star plus the
* exact "4.5/5" text is honest and much easier to maintain.
*
* A null rating means "not rated"; it must never appear as "null/5".
* `== null` is intentional here because it covers both null and an
* accidentally missing value.
*/
const ratingDisplay = book.rating == null
? '<span class="rating-not-set">Not rated</span>'
: `<span class="stars" aria-hidden="true">★</span><span>${book.rating}/5</span>`;
const tags = book.tags.map(tag => `<span class="tag">${tag}</span>`).join('');
let dateDisplay;
if (book.dateRead) {
dateDisplay = this.formatDate(book.dateRead);
} else if (book.status === 'read') {
dateDisplay = '-';
} else {
dateDisplay = 'Not read yet';
}
const reviewLink = book.review ? `<a href="${book.review}" target="_blank" class="review-link"><i class="fas fa-external-link-alt"></i> Read Review</a>` : '';
const formatIcon = this.getFormatIcon(book.format);
let statusText = '';
if (book.status === 'read') {
statusText = 'Read';
} else if (book.status === 'want-to-read') {
statusText = 'Want to Read';
} else if (book.status === 'reading') {
statusText = 'Reading';
}
/*
* Cover-image performance notes:
*
* - loading="lazy" tells the browser that it may wait to download a
* cover until that cover is getting close to the visible screen.
* Without it, the initial "Read" view tries to load hundreds of
* covers even though the visitor can see only the first few.
*
* - decoding="async" tells the browser that decoding an image should
* not block other page work. The browser can display each cover when
* it is ready while keeping scrolling and controls responsive.
*
* These are built-in HTML features. They need no JavaScript library,
* configuration, or future maintenance, which is ideal for this small
* static hobby site.
*/
return `
<div class="book-card">
<div class="book-status status-${book.status}">
${statusText}
</div>
<img
src="${book.cover}"
alt="${book.title}"
class="book-cover"
loading="lazy"
decoding="async"
onerror="this.src='https://via.placeholder.com/200x280/cccccc/666666?text=No+Cover'"
>
<div class="book-info">
<h3 class="book-title">${book.title}</h3>
<p class="book-author">by ${book.author}</p>
<div class="book-details">
<div class="book-rating">
${ratingDisplay}
</div>
<div class="book-date">
<i class="fas fa-calendar"></i>
${dateDisplay}
${formatIcon ? `<i class="${formatIcon} format-icon"></i>` : ''}
</div>
<div class="book-language">
<i class="fas fa-globe"></i>
${this.getLanguageName(book.language)}
</div>
<div class="book-tags">
${tags}
</div>
${reviewLink ? `<div class="book-review">${reviewLink}</div>` : ''}
</div>
</div>
</div>
`;
}
formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
updateStats() {
// dynamic update of reading stats
const totalBooks = this.books.length;
const readBooks = this.books.filter(book => book.status === 'read').length;
const readingBooks = this.books.filter(book => book.status === 'reading').length;
const wantToReadBooks = this.books.filter(book => book.status === 'want-to-read').length;
document.getElementById('totalBooks').textContent = totalBooks;
document.getElementById('readBooks').textContent = readBooks;
document.getElementById('readingBooks').textContent = readingBooks;
document.getElementById('wantToReadBooks').textContent = wantToReadBooks;
}
}
// Initialize the gallery when the page loads
document.addEventListener('DOMContentLoaded', () => {
new BookGallery();
});