diff options
| author | Paul Buetow <paul@buetow.org> | 2026-01-25 10:51:58 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-01-25 10:51:58 +0200 |
| commit | 3cf0ed0cba706c45833ad06bddfce2acefca6a37 (patch) | |
| tree | ea90532bb51b2d4bfff0f6a8e2f3c441b1b5707f /js | |
Add sci-fi book showcase with 54 books, covers, and summaries
Interactive HTML page featuring sci-fi book collection with:
- Responsive grid layout with book cover thumbnails
- Click-to-expand modal with cover, metadata, and summary
- Filter by author, format, and text search
- All 54 covers and summaries stored locally for offline use
- Authors include Brandhorst, Reynolds, Clarke, Banks, Simmons
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'js')
| -rw-r--r-- | js/app.js | 301 |
1 files changed, 301 insertions, 0 deletions
diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..3e7f1e8 --- /dev/null +++ b/js/app.js @@ -0,0 +1,301 @@ +/** + * Sci-Fi Books Showcase Application + * Fully offline version - uses local cover images and pre-fetched summaries. + */ + +// State management +const state = { + books: [], + filteredBooks: [], + currentFilter: { + author: 'all', + format: 'all', + search: '' + } +}; + +// DOM elements cache +const elements = { + booksGrid: null, + bookCount: null, + authorFilter: null, + formatFilter: null, + searchInput: null, + modal: null, + modalContent: null +}; + +// Initialize the application +async function init() { + cacheElements(); + await loadBooks(); + setupFilters(); + setupModal(); + renderBooks(); +} + +// Cache DOM element references +function cacheElements() { + elements.booksGrid = document.getElementById('books-grid'); + elements.bookCount = document.getElementById('book-count'); + elements.authorFilter = document.getElementById('author-filter'); + elements.formatFilter = document.getElementById('format-filter'); + elements.searchInput = document.getElementById('search-input'); + elements.modal = document.getElementById('book-modal'); + elements.modalContent = document.getElementById('modal-content'); +} + +// Load books from JSON file +async function loadBooks() { + try { + const response = await fetch('data/books.json'); + if (!response.ok) throw new Error('Failed to load books'); + const data = await response.json(); + state.books = data.books; + state.filteredBooks = [...state.books]; + populateAuthorFilter(); + } catch (error) { + console.error('Error loading books:', error); + elements.booksGrid.innerHTML = ` + <div class="error-message"> + <p>Failed to load books. Please try refreshing the page.</p> + </div> + `; + } +} + +// Populate author filter dropdown with unique authors +function populateAuthorFilter() { + const authors = [...new Set(state.books.map(book => book.author))].sort(); + authors.forEach(author => { + const option = document.createElement('option'); + option.value = author; + option.textContent = author; + elements.authorFilter.appendChild(option); + }); +} + +// Setup filter event listeners +function setupFilters() { + elements.authorFilter.addEventListener('change', handleFilterChange); + elements.formatFilter.addEventListener('change', handleFilterChange); + elements.searchInput.addEventListener('input', debounce(handleFilterChange, 300)); +} + +// Handle filter changes and update displayed books +function handleFilterChange() { + state.currentFilter.author = elements.authorFilter.value; + state.currentFilter.format = elements.formatFilter.value; + state.currentFilter.search = elements.searchInput.value.toLowerCase().trim(); + + state.filteredBooks = state.books.filter(book => { + const matchesAuthor = state.currentFilter.author === 'all' || + book.author === state.currentFilter.author; + const matchesFormat = state.currentFilter.format === 'all' || + book.format.toLowerCase() === state.currentFilter.format; + const matchesSearch = !state.currentFilter.search || + book.title.toLowerCase().includes(state.currentFilter.search) || + book.author.toLowerCase().includes(state.currentFilter.search); + + return matchesAuthor && matchesFormat && matchesSearch; + }); + + renderBooks(); +} + +// Get cover image URL - uses local file if available, otherwise shows placeholder +function getCoverUrl(book) { + if (book.coverLocal) { + return book.coverLocal; + } + return null; +} + +// Render books grid +function renderBooks() { + if (state.filteredBooks.length === 0) { + elements.booksGrid.innerHTML = ` + <div class="no-results"> + <p>No books found matching your filters.</p> + </div> + `; + elements.bookCount.textContent = '0 books'; + return; + } + + elements.bookCount.textContent = `${state.filteredBooks.length} book${state.filteredBooks.length !== 1 ? 's' : ''}`; + + elements.booksGrid.innerHTML = state.filteredBooks.map(book => { + const coverUrl = getCoverUrl(book); + return ` + <article class="book-card" tabindex="0" data-book-id="${book.id}" role="button" aria-label="View details for ${escapeHtml(book.title)}"> + <div class="cover-container"> + ${coverUrl ? ` + <img + src="${coverUrl}" + alt="Cover of ${escapeHtml(book.title)}" + loading="lazy" + onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';" + > + <div class="cover-placeholder" style="display: none;"> + <span class="book-icon">📚</span> + <span class="placeholder-title">${escapeHtml(book.title)}</span> + </div> + ` : ` + <div class="cover-placeholder"> + <span class="book-icon">📚</span> + <span class="placeholder-title">${escapeHtml(book.title)}</span> + </div> + `} + </div> + <div class="card-info"> + <h3>${escapeHtml(book.title)}</h3> + <p class="author">${escapeHtml(book.author)}</p> + <div class="meta"> + <span class="format-badge ${book.format.toLowerCase()}">${book.format}</span> + <span class="format-badge">${book.year}</span> + </div> + </div> + </article> + `}).join(''); + + // Add click handlers to book cards + document.querySelectorAll('.book-card').forEach(card => { + card.addEventListener('click', () => openModal(parseInt(card.dataset.bookId))); + card.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openModal(parseInt(card.dataset.bookId)); + } + }); + }); +} + +// Setup modal functionality +function setupModal() { + // Close on backdrop click + elements.modal.addEventListener('click', (e) => { + if (e.target === elements.modal) { + closeModal(); + } + }); + + // Close on Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && elements.modal.classList.contains('active')) { + closeModal(); + } + }); +} + +// Open modal with book details +function openModal(bookId) { + const book = state.books.find(b => b.id === bookId); + if (!book) return; + + const coverUrl = getCoverUrl(book); + + // Build summary section + let summaryHtml; + if (book.summary) { + summaryHtml = ` + <h3>Summary</h3> + <p>${escapeHtml(book.summary)}</p> + ${book.summarySource ? `<p class="summary-source">Source: ${book.summarySource}</p>` : ''} + `; + } else { + summaryHtml = ` + <h3>Summary</h3> + <p class="no-summary">No summary available for this book.</p> + `; + } + + // Render modal content + elements.modalContent.innerHTML = ` + <button class="modal-close" aria-label="Close modal">×</button> + <div class="modal-body"> + <div class="modal-cover"> + ${coverUrl ? ` + <img + src="${coverUrl}" + alt="Cover of ${escapeHtml(book.title)}" + onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';" + > + <div class="cover-placeholder" style="display: none;"> + <span class="book-icon">📚</span> + <span class="placeholder-title">${escapeHtml(book.title)}</span> + </div> + ` : ` + <div class="cover-placeholder"> + <span class="book-icon">📚</span> + <span class="placeholder-title">${escapeHtml(book.title)}</span> + </div> + `} + </div> + <div class="modal-details"> + <h2>${escapeHtml(book.title)}</h2> + <p class="author">${escapeHtml(book.author)}</p> + <div class="modal-meta"> + <div class="meta-item"> + <span class="label">Year</span> + <span class="value">${book.year}</span> + </div> + <div class="meta-item"> + <span class="label">Format</span> + <span class="value">${book.format}</span> + </div> + <div class="meta-item"> + <span class="label">Language</span> + <span class="value">${book.language === 'de' ? 'German' : 'English'}</span> + </div> + ${book.isbn ? ` + <div class="meta-item"> + <span class="label">ISBN</span> + <span class="value">${book.isbn}</span> + </div> + ` : ''} + </div> + <div class="modal-summary"> + ${summaryHtml} + </div> + </div> + </div> + `; + + // Add close button handler + elements.modalContent.querySelector('.modal-close').addEventListener('click', closeModal); + + // Show modal + elements.modal.classList.add('active'); + document.body.style.overflow = 'hidden'; +} + +// Close modal +function closeModal() { + elements.modal.classList.remove('active'); + document.body.style.overflow = ''; +} + +// Utility: Escape HTML to prevent XSS +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Utility: Debounce function for search input +function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +// Initialize when DOM is ready +document.addEventListener('DOMContentLoaded', init); |
