1/** 2 * Blog Management System 3 * Handles dynamic loading, filtering, and display of blog posts 4 */ 5 6class BlogManager { 7 constructor() { 8 this.posts = []; 9 this.categories = []; 10 this.tags = []; 11 this.currentFilter = 'all'; 12 this.currentCategory = 'all'; 13 this.searchTerm = ''; 14 this.init(); 15 } 16 17 async init() { 18 try { 19 await this.loadBlogData(); 20 this.setupEventListeners(); 21 this.renderBlogPosts(); 22 this.renderFilters(); 23 } catch (error) { 24 console.error('Failed to initialize blog:', error); 25 this.renderError(); 26 } 27 } 28 29 async loadBlogData() { 30 try { 31 const response = await fetch('./blog-data.json?v=' + Date.now()); 32 if (!response.ok) throw new Error('Failed to load blog data'); 33 34 const data = await response.json(); 35 this.posts = data.posts.filter(post => post.published && post.category !== 'Reviews'); 36 this.categories = data.categories; 37 this.tags = data.tags; 38 39 // Sort posts by date (newest first) 40 this.posts.sort((a, b) => new Date(b.date) - new Date(a.date)); 41 42 43 } catch (error) { 44 console.error('Error loading blog data:', error); 45 // Fallback to hardcoded data with correct authors 46 this.posts = [ 47 { 48 id: 1, 49 title: "Mesh-Mapper Project on Hackster.io", 50 url: "https://www.hackster.io/colonelpanic/mesh-mapper-drone-remote-id-mapping-and-mesh-alerts-8e7c61", 51 excerpt: "Comprehensive project documentation featuring dual-core ESP32 drone Remote ID detection, real-time web mapping, Meshtastic mesh alerts, FAA database integration, and custom PCB design with 2,595 views and full build instructions.", 52 date: "2025-05-31", 53 author: "colonelpanic", 54 category: "Projects", 55 tags: ["ESP32", "Drone", "RemoteID"], 56 published: true 57 }, 58 { 59 id: 2, 60 title: "mesh-detect Featured in CNX Software", 61 url: "https://www.cnx-software.com/2025/06/05/map-remote-id-enabled-drones-with-esp32-c3-s3-and-meshtastic-lora-modules/", 62 excerpt: "CNX Software covers our mesh-detect v2 project, highlighting its capabilities for mapping Remote ID-enabled drones using ESP32-C3/S3 and Meshtastic LoRa modules for distributed detection networks with 5-15km range.", 63 date: "2024-06-05", 64 author: "CNX Software", 65 category: "Press", 66 tags: ["ESP32", "Coverage"], 67 published: true 68 }, 69 { 70 id: 3, 71 title: "Drone Mesh Mapper Update", 72 url: "https://wrewtopia.com/read/2025-07-13", 73 excerpt: "Real-world deployment update on the mesh-detect system running 24/7/365 on a Raspberry Pi 4, successfully detecting and tracking drone activity with permanent placement in an office environment.", 74 date: "2025-07-13", 75 author: "wrewtopia", 76 category: "Reviews", 77 tags: ["Update", "Deployment"], 78 published: true 79 }, 80 { 81 id: 4, 82 title: "Mesh Detect Setup (Part II)", 83 url: "https://wrewtopia.com/read/2025-06-15", 84 excerpt: "Comprehensive setup guide for the drone mesh mapper software, including Python environment management with Uv, serial connection troubleshooting, and step-by-step instructions for getting the system operational.", 85 date: "2025-06-15", 86 author: "wrewtopia", 87 category: "Tutorials", 88 tags: ["Setup", "Guide"], 89 published: true 90 }, 91 { 92 id: 5, 93 title: "Mesh Detect Setup (Part I)", 94 url: "https://wrewtopia.com/read/2025-06-14", 95 excerpt: "Initial setup experience with the mesh-detect hardware and software, covering Python environment issues, web interface functionality, and first impressions of the drone detection system's capabilities.", 96 date: "2025-06-14", 97 author: "wrewtopia", 98 category: "Tutorials", 99 tags: ["Setup", "Guide"], 100 published: true 101 }, 102 { 103 id: 6, 104 title: "Mesh Detect", 105 url: "https://wrewtopia.com/read/2025-06-12-2", 106 excerpt: "Introduction to the Mesh Detect project - an inexpensive hardware solution for detecting WiFi and Bluetooth devices, with specialized firmware for drone identification and tracking capabilities.", 107 date: "2025-06-12", 108 author: "wrewtopia", 109 category: "Reviews", 110 tags: ["Introduction", "Hardware"], 111 published: true 112 } 113 ];
114 this.categories = ['Projects', 'Tutorials', 'Updates', 'Press']; 115 this.tags = []; 116 117 // Sort posts by date (newest first) 118 this.posts.sort((a, b) => new Date(b.date) - new Date(a.date)); 119 120 121 } 122 } 123 124 loadStaticContent() { 125 // Fallback to extract existing static content 126 const existingPosts = document.querySelectorAll('.blog-post'); 127 this.posts = Array.from(existingPosts).map((post, index) => { 128 const title = post.querySelector('h3 a')?.textContent || 'Untitled'; 129 const url = post.querySelector('h3 a')?.href || '#'; 130 const excerpt = post.querySelector('p')?.textContent || ''; 131 const date = post.querySelector('.blog-meta')?.textContent || ''; 132 133 return { 134 id: `post-${index}`, 135 title, 136 url, 137 excerpt, 138 date, 139 category: 'General', 140 tags: [], 141 author: 'colonelpanic', // Default to colonelpanic for existing posts 142 published: true 143 }; 144 }); 145 } 146 147 setupEventListeners() { 148 // Search functionality 149 const searchInput = document.getElementById('blog-search'); 150 if (searchInput) { 151 searchInput.addEventListener('input', (e) => { 152 this.searchTerm = e.target.value.toLowerCase(); 153 this.renderBlogPosts(); 154 }); 155 } 156 157 // Category filter 158 const categoryFilter = document.getElementById('category-filter'); 159 if (categoryFilter) { 160 categoryFilter.addEventListener('change', (e) => { 161 this.currentCategory = e.target.value; 162 this.renderBlogPosts(); 163 }); 164 } 165 166 // Tag filter buttons (will be created dynamically) 167 document.addEventListener('click', (e) => { 168 if (e.target.classList.contains('tag-filter')) { 169 const tag = e.target.dataset.tag; 170 this.toggleTagFilter(tag); 171 this.renderBlogPosts(); 172 } 173 }); 174 } 175 176 toggleTagFilter(tag) { 177 if (this.currentFilter === tag) { 178 this.currentFilter = 'all'; 179 } else { 180 this.currentFilter = tag; 181 } 182 this.updateTagButtons(); 183 } 184 185 updateTagButtons() { 186 document.querySelectorAll('.tag-filter').forEach(btn => { 187 btn.classList.toggle('active', btn.dataset.tag === this.currentFilter); 188 }); 189 } 190 191 filterPosts() { 192 return this.posts.filter(post => { 193 // Search filter 194 if (this.searchTerm) { 195 const searchMatch = 196 post.title.toLowerCase().includes(this.searchTerm) || 197 post.excerpt.toLowerCase().includes(this.searchTerm) || 198 post.tags.some(tag => tag.toLowerCase().includes(this.searchTerm)); 199 if (!searchMatch) return false; 200 } 201 202 // Category filter 203 if (this.currentCategory !== 'all' && post.category !== this.currentCategory) { 204 return false; 205 } 206 207 // Tag filter 208 if (this.currentFilter !== 'all' && !post.tags.includes(this.currentFilter)) { 209 return false; 210 } 211 212 return true; 213 }); 214 } 215 216 renderBlogPosts() { 217 this.renderFeaturedPosts(); 218 this.renderRegularPosts(); 219 } 220 221 renderFeaturedPosts() { 222 const featuredContainer = document.querySelector('#blog .featured-posts'); 223 if (!featuredContainer) { 224 // Create featured posts container if it doesn't exist 225 const blogContainer = document.querySelector('#blog .container'); 226 const regularGrid = blogContainer.querySelector('.grid-2'); 227 if (blogContainer && regularGrid) { 228 const featuredSection = document.createElement('div'); 229 featuredSection.className = 'featured-posts'; 230 blogContainer.insertBefore(featuredSection, regularGrid); 231 } 232 } 233 234 // Featured posts should always show regardless of filters 235 const featuredPosts = this.posts.filter(post => post.featured === true); 236 const container = document.querySelector('#blog .featured-posts'); 237 238 if (!container) return; 239 240 if (featuredPosts.length === 0) { 241 container.innerHTML = ''; 242 return; 243 } 244 245 container.innerHTML = ` 246 <div class="featured-posts-grid"> 247 ${featuredPosts.map(post => `
248 <article class="featured-post" data-id="${post.id}"> 249 ${post.image ? ` 250 <div class="featured-image"> 251 <img src="${post.image}" alt="${post.title}" class="featured-img" /> 252 <div class="featured-image-overlay"></div> 253 </div> 254 ` : ''} 255 <div class="featured-post-content"> 256 <div class="featured-meta"> 257 <span class="featured-category">${post.category}</span> 258 <span class="featured-date">${this.formatDate(post.date)}</span> 259 <span class="featured-author ${post.author === 'colonelpanic' ? 'author-colonelpanic' : ''}">${this.formatAuthor(post.author)}</span> 260 </div> 261 <h2 class="featured-post-title"> 262 <a href="${post.url || '#'}" ${post.url ? 'target="_blank" rel="noopener"' : ''}>${post.title}</a> 263 </h2> 264 <p class="featured-excerpt">${post.excerpt}</p> 265 <div class="featured-tags"> 266 ${post.tags.map(tag => `<span class="featured-tag">${tag}</span>`).join('')} 267 </div> 268 <div class="featured-cta"> 269 <a href="${post.url || '#'}" ${post.url ? 'target="_blank" rel="noopener"' : ''} class="featured-link">Read Full Article</a> 270 </div> 271 </div> 272 </article> 273 `).join('')} 274 </div> 275 `; 276 277 // No image removal for featured posts - we want to keep the images! 278 } 279 280 renderRegularPosts() { 281 const gridContainer = document.querySelector('#blog .grid-2'); 282 if (!gridContainer) return; 283 284 const filteredPosts = this.filterPosts(); 285 const regularPosts = filteredPosts.filter(post => post.featured !== true); 286 287 if (regularPosts.length === 0 && filteredPosts.filter(post => post.featured === true).length === 0) { 288 gridContainer.innerHTML = ` 289 <div class="no-posts"> 290 <h3>No articles found</h3> 291 <p>Try adjusting your filters or search terms.</p> 292 </div> 293 `; 294 return; 295 } 296 297 if (regularPosts.length === 0) { 298 // Just add the header, no posts 299 const blogContainer = document.querySelector('#blog .container'); 300 let headerElement = blogContainer.querySelector('.more-articles-header'); 301 if (headerElement) { 302 headerElement.remove(); 303 } 304 gridContainer.innerHTML = ''; 305 return; 306 } 307 308 // Add "More Articles" header before the grid 309 const blogContainer = document.querySelector('#blog .container'); 310 let headerElement = blogContainer.querySelector('.more-articles-header'); 311 312 if (!headerElement) { 313 headerElement = document.createElement('div'); 314 headerElement.className = 'more-articles-header'; 315 headerElement.innerHTML = '<h3 class="more-articles-title">More Articles</h3>'; 316 blogContainer.insertBefore(headerElement, gridContainer); 317 } 318 319 gridContainer.innerHTML = regularPosts.map(post => ` 320 <article class="blog-post" data-id="${post.id}"> 321 <div class="blog-meta"> 322 <span class="blog-date">${this.formatDate(post.date)}</span> 323 <span class="blog-author ${post.author === 'colonelpanic' ? 'author-colonelpanic' : ''}">${this.formatAuthor(post.author)}</span> 324 </div> 325 <div class="blog-category">${post.category}</div> 326 <h3><a href="${post.url || '#'}" ${post.url ? 'target="_blank" rel="noopener"' : ''}>${post.title}</a></h3> 327 <p>${post.excerpt}</p> 328 <div class="blog-tags"> 329 ${post.tags.map(tag => `<span class="tag">${tag}</span>`).join('')} 330 </div> 331 </article> 332 `).join(''); 333 334 // Remove any stray images from regular posts only 335 setTimeout(() => { 336 const blogImages = gridContainer.querySelectorAll('img:not(.featured-img)');
337 blogImages.forEach(img => { 338 if (!img.closest('.featured-posts')) { 339 img.remove(); 340 } 341 }); 342 }, 100); 343 } 344 345 renderFilters() { 346 // Add search and filter controls 347 const blogSection = document.querySelector('#blog .container'); 348 if (!blogSection) return; 349 350 const filtersHTML = ` 351 <div class="blog-controls"> 352 <div class="search-container"> 353 <input type="text" id="blog-search" placeholder="Search articles..." /> 354 </div> 355 <div class="filter-container"> 356 <select id="category-filter"> 357 <option value="all">All Categories</option> 358 ${this.categories.map(cat => `<option value="${cat}">${cat}</option>`).join('')} 359 </select> 360 </div> 361 <div class="tag-filters"> 362 <button class="tag-filter active" data-tag="all">All</button> 363 ${this.tags.slice(0, 8).map(tag => 364 `<button class="tag-filter" data-tag="${tag}">${tag}</button>` 365 ).join('')} 366 </div> 367 </div> 368 `; 369 370 // Insert after the section title 371 const title = blogSection.querySelector('.section-title'); 372 if (title) { 373 title.insertAdjacentHTML('afterend', filtersHTML); 374 } 375 } 376 377 378 379 formatDate(dateString) { 380 const date = new Date(dateString); 381 return date.toLocaleDateString('en-US', { 382 year: 'numeric', 383 month: 'long', 384 day: 'numeric' 385 }); 386 } 387 388 formatAuthor(author) { 389 if (!author) return 'Unknown'; 390 if (author === 'colonelpanic') { 391 return 'Colonel Panic'; 392 } 393 return `by ${author}`; 394 } 395 396 renderError() { 397 const container = document.querySelector('#blog .grid-2'); 398 if (container) { 399 container.innerHTML = ` 400 <div class="blog-error"> 401 <h3>Unable to load articles</h3> 402 <p>Please check your connection and try again.</p> 403 </div> 404 `; 405 } 406 } 407} 408 409// Initialize blog manager when DOM is loaded 410document.addEventListener('DOMContentLoaded', () => { 411 window.blogManager = new BlogManager(); 412}); 413 414// Export for use in other scripts 415if (typeof module !== 'undefined' && module.exports) { 416 module.exports = BlogManager; 417}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.