/** * Blog Listing Page - Client-Side Logic * Handles fetching, filtering, searching, sorting, and pagination of blog posts */ // State management let allPosts = []; let filteredPosts = []; let currentPage = 1; const postsPerPage = 9; // Filter state const activeFilters = { search: '', category: '', sort: 'date-desc' }; // CSRF token (fetched on page load) let csrfToken = null; /** * Initialize the blog page */ async function init() { try { // Fetch CSRF token first (required for newsletter subscription) await fetchCsrfToken(); await loadPosts(); attachEventListeners(); } catch (error) { console.error('Error initializing blog:', error); showError('Failed to load blog posts. Please refresh the page.'); } } /** * Fetch CSRF token from server * Required because nginx serves blog.html as static file (bypasses setCsrfToken middleware) */ async function fetchCsrfToken() { try { const response = await fetch('/api/csrf-token'); const data = await response.json(); if (data.csrfToken) { csrfToken = data.csrfToken; console.log('CSRF token fetched successfully'); } } catch (error) { console.warn('Failed to fetch CSRF token:', error); // Non-critical - newsletter subscription will fail but blog browsing still works } } /** * Load all published blog posts from API */ async function loadPosts() { try { const response = await fetch('/api/blog'); const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Failed to load posts'); } allPosts = data.posts || []; filteredPosts = [...allPosts]; // Apply initial sorting sortPosts(); // Render initial view renderPosts(); updateResultsCount(); } catch (error) { console.error('Error loading posts:', error); showError('Failed to load blog posts'); } } /** * Render blog posts grid */ function renderPosts() { const gridEl = document.getElementById('blog-grid'); const emptyStateEl = document.getElementById('empty-state'); if (filteredPosts.length === 0) { gridEl.innerHTML = ''; emptyStateEl.classList.remove('hidden'); document.getElementById('pagination').classList.add('hidden'); return; } emptyStateEl.classList.add('hidden'); // Calculate pagination const startIndex = (currentPage - 1) * postsPerPage; const endIndex = startIndex + postsPerPage; const postsToShow = filteredPosts.slice(startIndex, endIndex); // Render posts const postsHTML = postsToShow.map(post => renderPostCard(post)).join(''); gridEl.innerHTML = postsHTML; // Render pagination renderPagination(); // Scroll to top when changing pages (except initial load) if (currentPage > 1) { window.scrollTo({ top: 0, behavior: 'smooth' }); } } /** * Render a single post card */ function renderPostCard(post) { const publishedDate = new Date(post.published_at); const formattedDate = publishedDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); // Calculate read time (rough estimate: 200 words per minute) const wordCount = post.content ? post.content.split(/\s+/).length : 0; const readTime = Math.max(1, Math.ceil(wordCount / 200)); // Truncate excerpt to 150 characters const excerpt = post.excerpt ? (post.excerpt.length > 150 ? `${post.excerpt.substring(0, 150) }...` : post.excerpt) : 'Read more...'; // Get category color const categoryColor = getCategoryColor(post.category); return `
${post.featured_image ? `
${escapeHtml(post.title)}
` : `
`}
${post.category ? ` ${escapeHtml(post.category)} ` : ''}

${escapeHtml(post.title)}

${escapeHtml(excerpt)}

${readTime} min read
${post.tags && post.tags.length > 0 ? `
${post.tags.slice(0, 3).map(tag => ` ${escapeHtml(tag)} `).join('')} ${post.tags.length > 3 ? `+${post.tags.length - 3} more` : ''}
` : ''}
`; } /** * Get category color gradient */ function getCategoryColor(category) { const colorMap = { 'Framework Updates': 'from-blue-400 to-blue-600', 'Case Studies': 'from-purple-400 to-purple-600', 'Research': 'from-green-400 to-green-600', 'Implementation': 'from-yellow-400 to-yellow-600', 'Community': 'from-pink-400 to-pink-600' }; return colorMap[category] || 'from-gray-400 to-gray-600'; } /** * Render pagination controls */ function renderPagination() { const paginationEl = document.getElementById('pagination'); const totalPages = Math.ceil(filteredPosts.length / postsPerPage); if (totalPages <= 1) { paginationEl.classList.add('hidden'); return; } paginationEl.classList.remove('hidden'); const prevBtn = document.getElementById('prev-page'); const nextBtn = document.getElementById('next-page'); const pageNumbersEl = document.getElementById('page-numbers'); // Update prev/next buttons prevBtn.disabled = currentPage === 1; nextBtn.disabled = currentPage === totalPages; // Render page numbers let pageNumbersHTML = ''; const maxVisiblePages = 5; let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2)); const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); // Adjust start if we're near the end if (endPage - startPage < maxVisiblePages - 1) { startPage = Math.max(1, endPage - maxVisiblePages + 1); } // First page + ellipsis if (startPage > 1) { pageNumbersHTML += ` ${startPage > 2 ? '...' : ''} `; } // Visible page numbers for (let i = startPage; i <= endPage; i++) { const isActive = i === currentPage; pageNumbersHTML += ` `; } // Ellipsis + last page if (endPage < totalPages) { pageNumbersHTML += ` ${endPage < totalPages - 1 ? '...' : ''} `; } pageNumbersEl.innerHTML = pageNumbersHTML; } /** * Apply filters and search */ function applyFilters() { // Reset to first page when filters change currentPage = 1; // Start with all posts filteredPosts = [...allPosts]; // Apply search if (activeFilters.search) { const searchLower = activeFilters.search.toLowerCase(); filteredPosts = filteredPosts.filter(post => { return ( post.title.toLowerCase().includes(searchLower) || (post.content && post.content.toLowerCase().includes(searchLower)) || (post.excerpt && post.excerpt.toLowerCase().includes(searchLower)) || (post.tags && post.tags.some(tag => tag.toLowerCase().includes(searchLower))) ); }); } // Apply category filter if (activeFilters.category) { filteredPosts = filteredPosts.filter(post => post.category === activeFilters.category); } // Sort sortPosts(); // Update UI renderPosts(); updateResultsCount(); updateActiveFiltersDisplay(); } /** * Sort posts based on active sort option */ function sortPosts() { switch (activeFilters.sort) { case 'date-desc': filteredPosts.sort((a, b) => new Date(b.published_at) - new Date(a.published_at)); break; case 'date-asc': filteredPosts.sort((a, b) => new Date(a.published_at) - new Date(b.published_at)); break; case 'title-asc': filteredPosts.sort((a, b) => a.title.localeCompare(b.title)); break; case 'title-desc': filteredPosts.sort((a, b) => b.title.localeCompare(a.title)); break; } } /** * Update results count display */ function updateResultsCount() { const countEl = document.getElementById('post-count'); if (countEl) { countEl.textContent = filteredPosts.length; } } /** * Update active filters display */ function updateActiveFiltersDisplay() { const activeFiltersEl = document.getElementById('active-filters'); const filterTagsEl = document.getElementById('filter-tags'); const hasActiveFilters = activeFilters.search || activeFilters.category; if (!hasActiveFilters) { activeFiltersEl.classList.add('hidden'); return; } activeFiltersEl.classList.remove('hidden'); let tagsHTML = ''; if (activeFilters.search) { tagsHTML += ` Search: "${escapeHtml(activeFilters.search)}" `; } if (activeFilters.category) { tagsHTML += ` Category: ${escapeHtml(activeFilters.category)} `; } filterTagsEl.innerHTML = tagsHTML; } /** * Clear all filters */ function clearFilters() { activeFilters.search = ''; activeFilters.category = ''; // Reset UI elements document.getElementById('search-input').value = ''; document.getElementById('category-filter').value = ''; // Reapply filters applyFilters(); } /** * Remove specific filter */ function removeFilter(filterType) { if (filterType === 'search') { activeFilters.search = ''; document.getElementById('search-input').value = ''; } else if (filterType === 'category') { activeFilters.category = ''; document.getElementById('category-filter').value = ''; } applyFilters(); } /** * Attach event listeners */ function attachEventListeners() { // Search input (debounced) const searchInput = document.getElementById('search-input'); let searchTimeout; searchInput.addEventListener('input', e => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { activeFilters.search = e.target.value.trim(); applyFilters(); }, 300); }); // Category filter const categoryFilter = document.getElementById('category-filter'); categoryFilter.addEventListener('change', e => { activeFilters.category = e.target.value; applyFilters(); }); // Sort select const sortSelect = document.getElementById('sort-select'); sortSelect.addEventListener('change', e => { activeFilters.sort = e.target.value; applyFilters(); }); // Clear filters button const clearFiltersBtn = document.getElementById('clear-filters'); clearFiltersBtn.addEventListener('click', clearFilters); // Pagination - prev/next buttons const prevBtn = document.getElementById('prev-page'); const nextBtn = document.getElementById('next-page'); prevBtn.addEventListener('click', () => { if (currentPage > 1) { currentPage--; renderPosts(); } }); nextBtn.addEventListener('click', () => { const totalPages = Math.ceil(filteredPosts.length / postsPerPage); if (currentPage < totalPages) { currentPage++; renderPosts(); } }); // Pagination - page numbers (event delegation) const pageNumbersEl = document.getElementById('page-numbers'); pageNumbersEl.addEventListener('click', e => { const pageBtn = e.target.closest('.page-number'); if (pageBtn) { currentPage = parseInt(pageBtn.dataset.page, 10); renderPosts(); } }); // Remove filter tags (event delegation) const filterTagsEl = document.getElementById('filter-tags'); filterTagsEl.addEventListener('click', e => { const removeBtn = e.target.closest('[data-remove-filter]'); if (removeBtn) { const filterType = removeBtn.dataset.removeFilter; removeFilter(filterType); } }); } /** * Show error message */ function showError(message) { const gridEl = document.getElementById('blog-grid'); gridEl.innerHTML = `

Error

${escapeHtml(message)}

`; } /** * Escape HTML to prevent XSS */ function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Newsletter Modal Functionality */ function setupNewsletterModal() { const modal = document.getElementById('newsletter-modal'); const openBtn = document.getElementById('open-newsletter-modal'); const closeBtn = document.getElementById('close-newsletter-modal'); const cancelBtn = document.getElementById('cancel-newsletter'); const form = document.getElementById('newsletter-form'); const successMsg = document.getElementById('newsletter-success'); const errorMsg = document.getElementById('newsletter-error'); const errorText = document.getElementById('newsletter-error-message'); const submitBtn = document.getElementById('newsletter-submit'); // Open modal if (openBtn) { openBtn.addEventListener('click', () => { modal.classList.remove('hidden'); document.getElementById('newsletter-email').focus(); }); } // Close modal function closeModal() { modal.classList.add('hidden'); form.reset(); successMsg.classList.add('hidden'); errorMsg.classList.add('hidden'); } if (closeBtn) { closeBtn.addEventListener('click', closeModal); } if (cancelBtn) { cancelBtn.addEventListener('click', closeModal); } // Close on backdrop click modal.addEventListener('click', (e) => { if (e.target === modal) { closeModal(); } }); // Close on Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !modal.classList.contains('hidden')) { closeModal(); } }); // Handle form submission if (form) { form.addEventListener('submit', async (e) => { e.preventDefault(); // Reset messages successMsg.classList.add('hidden'); errorMsg.classList.add('hidden'); const email = document.getElementById('newsletter-email').value; const name = document.getElementById('newsletter-name').value; // Disable submit button submitBtn.disabled = true; submitBtn.textContent = 'Subscribing...'; try { // Ensure we have a CSRF token if (!csrfToken) { await fetchCsrfToken(); } if (!csrfToken) { throw new Error('Unable to obtain CSRF token'); } const response = await fetch('/api/newsletter/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ email, name: name || null, source: 'blog' }) }); const data = await response.json(); if (response.ok && data.success) { // Show success message successMsg.classList.remove('hidden'); form.reset(); // Close modal after 2 seconds setTimeout(() => { closeModal(); }, 2000); } else { // Show error message errorText.textContent = data.error || 'Failed to subscribe. Please try again.'; errorMsg.classList.remove('hidden'); } } catch (error) { console.error('Newsletter subscription error:', error); errorText.textContent = 'Network error. Please check your connection and try again.'; errorMsg.classList.remove('hidden'); } finally { // Re-enable submit button submitBtn.disabled = false; submitBtn.textContent = 'Subscribe'; } }); } } // Initialize on page load document.addEventListener('DOMContentLoaded', () => { init(); setupNewsletterModal(); });