summaryrefslogtreecommitdiff
path: root/js
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-01-25 10:51:58 +0200
committerPaul Buetow <paul@buetow.org>2026-01-25 10:51:58 +0200
commit3cf0ed0cba706c45833ad06bddfce2acefca6a37 (patch)
treeea90532bb51b2d4bfff0f6a8e2f3c441b1b5707f /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.js301
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">&times;</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);