1/** 2 * UCL Blogs Grid Listing Module 3 * Handles public blogs directory card rendering, category badges, tag filters, text search, and state transitions 4 */ 5 6(function () { 7 'use strict'; 8 9 class UCLBlogList { 10 get api() { 11 return window.UCLBlogAPI; 12 } 13 14 constructor() { 15 this.allBlogs = []; 16 this.categories = []; 17 this.tags = []; 18 this.activeCategoryId = null; // null = all 19 this.activeTagId = null; // null = all 20 this.searchQuery = ""; 21 } 22 23 // Initialize directory page 24 async init() { 25 const gridContainer = document.getElementById('blogs-grid-container'); 26 if (!gridContainer) return; 27 28 gridContainer.innerHTML = ` 29 <div style="text-align: center; padding: 60px; width: 100%;"> 30 <i class="fa fa-spinner fa-spin" style="font-size: 32px; color: #192f59; margin-bottom: 15px;"></i> 31 <p style="font-size: 16px; color: #666; font-weight: 500;">Connecting to campus database...</p> 32 </div> 33 `; 34 35 try { 36 // 1. Fetch categories and tags to build filters 37 this.categories = await this.api.getCategories(); 38 this.tags = await this.api.getTags(); 39 40 // 2. Fetch all published blogs 41 this.allBlogs = await this.api.getBlogs({ status: 'published' }); 42 43 // 3. Render filter UI panels 44 this.renderFilterPanels(); 45 46 // 4. Render blogs grid 47 this.renderBlogsGrid(); 48 49 // 5. Wire up search listener 50 const searchInput = document.getElementById('blog-search'); 51 if (searchInput) { 52 searchInput.addEventListener('input', (e) => { 53 this.searchQuery = e.target.value.toLowerCase().trim(); 54 this.renderBlogsGrid(); 55 }); 56 } 57 58 } catch (error) { 59 console.error("Failed to initialize blogs directory:", error); 60 gridContainer.innerHTML = ` 61 <div style="text-align: center; padding: 40px; width: 100%;"> 62 <i class="fa fa-exclamation-triangle" style="font-size: 40px; color: #D22B2B; margin-bottom: 15px;"></i> 63 <p style="color: #D22B2B; font-size: 18px; font-weight: 600;">Failed to load university blogs.</p> 64 <p style="color: #777; font-size: 14px;">Error: ${error.message}</p> 65 </div> 66 `; 67 } 68 } 69 70 // Render dynamic category filter buttons and tag filter pills 71 renderFilterPanels() { 72 // A. Category Filter Buttons 73 const categoryContainer = document.getElementById('category-filters-container'); 74 if (categoryContainer) { 75 let html = `<button class="filter-btn active" id="btn-cat-all" onclick="UCLBlogListInstance.filterCategory(null)" style="padding: 8px 18px; border-radius: 4px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s;">All</button>`; 76 77 html += this.categories.map(cat => ` 78 <button class="filter-btn" id="btn-cat-${cat.id}" onclick="UCLBlogListInstance.filterCategory('${cat.id}')" style="padding: 8px 18px; border-radius: 4px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; margin-left: 5px;">${cat.name}</button> 79 `).join(''); 80 81 categoryContainer.innerHTML = html; 82 } 83 84 // B. Tags Filter Pills (Rendered dynamically if a #tag-filters-container exists) 85 const tagContainer = document.getElementById('tag-filters-container'); 86 if (tagContainer) { 87 if (this.tags.length === 0) { 88 tagContainer.style.display = 'none'; 89 return; 90 } 91 92 let html = `<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 15px;">
93 <span style="font-size:13px; color:#666; display:flex; align-items:center; margin-right:5px; font-weight:500;">Filter by Tag:</span> 94 <button class="tag-pill-btn active" id="btn-tag-all" onclick="UCLBlogListInstance.filterTag(null)">#Any</button>`; 95 96 html += this.tags.map(tag => ` 97 <button class="tag-pill-btn" id="btn-tag-${tag.id}" onclick="UCLBlogListInstance.filterTag('${tag.id}')">#${tag.name}</button> 98 `).join(''); 99 100 html += `</div>`; 101 tagContainer.innerHTML = html; 102 } 103 } 104 105 // Handle category filter selections 106 async filterCategory(categoryId) { 107 this.activeCategoryId = categoryId; 108 109 // Update UI class active states 110 const buttons = document.querySelectorAll('#category-filters-container .filter-btn'); 111 buttons.forEach(btn => btn.classList.remove('active')); 112 113 const targetBtnId = categoryId ? `btn-cat-${categoryId}` : 'btn-cat-all'; 114 const targetBtn = document.getElementById(targetBtnId); 115 if (targetBtn) targetBtn.classList.add('active'); 116 117 // Re-render 118 this.renderBlogsGrid(); 119 } 120 121 // Handle tag filter selections 122 async filterTag(tagId) { 123 this.activeTagId = tagId; 124 125 // Update tag pills class active states 126 const tagBtns = document.querySelectorAll('#tag-filters-container .tag-pill-btn'); 127 tagBtns.forEach(btn => btn.classList.remove('active')); 128 129 const targetTagId = tagId ? `btn-tag-${tagId}` : 'btn-tag-all'; 130 const targetTag = document.getElementById(targetTagId); 131 if (targetTag) targetTag.classList.add('active'); 132 133 // If a tag is selected, we fetch blogs from the database matching this tag, or do it in-memory 134 // To ensure 100% correct tag matching, we'll fetch from database if tagId is active, otherwise reset 135 const gridContainer = document.getElementById('blogs-grid-container'); 136 gridContainer.innerHTML = ` 137 <div style="text-align: center; padding: 40px; width: 100%;"> 138 <i class="fa fa-spinner fa-spin" style="font-size: 24px; color: #192f59; margin-bottom: 10px;"></i> 139 <p style="font-size: 14px; color: #777;">Filtering by tag...</p> 140 </div> 141 `; 142 143 try { 144 const filters = { status: 'published' }; 145 if (this.activeCategoryId) filters.category_id = this.activeCategoryId; 146 if (this.activeTagId) filters.tag_id = this.activeTagId; 147 148 this.allBlogs = await this.api.getBlogs(filters); 149 this.renderBlogsGrid(); 150 } catch (err) { 151 console.error("Tag filter API failed:", err); 152 gridContainer.innerHTML = `<div style="text-align: center; padding: 40px; width:100%; color:red;">Failed to filter tags.</div>`; 153 } 154 } 155 156 // Render standard grid with matching styling and responsive dimensions 157 renderBlogsGrid() { 158 const container = document.getElementById('blogs-grid-container'); 159 if (!container) return; 160 161 // Client-side filter: Search text and Category ID 162 const filteredBlogs = this.allBlogs.filter(blog => { 163 const matchesSearch = blog.title.toLowerCase().includes(this.searchQuery) || 164 (blog.excerpt && blog.excerpt.toLowerCase().includes(this.searchQuery)) || 165 (blog.content && blog.content.toLowerCase().includes(this.searchQuery)); 166 167 const matchesCategory = !this.activeCategoryId || blog.category_id === this.activeCategoryId; 168 169 return matchesSearch && matchesCategory; 170 }); 171 172 if (filteredBlogs.length === 0) { 173 container.innerHTML = ` 174 <div style="text-align: center; padding: 60px; width: 100%; background: #ffffff; border-radius: 6px; border: 1px dashed #ddd;"> 175 <i class="fa fa-info-circle" style="font-size: 32px; color: #888; margin-bottom: 15px;"></i> 176 <p style="font-size: 18px; font-weight: 500; color: #666; margin: 0;">No articles match your selection.</p> 177 <p style="font-size: 14px; color: #999; margin-top: 5px;">
177Try adjusting your search filters or clearing categories.</p> 178 </div> 179 `; 180 return; 181 } 182 183 container.innerHTML = filteredBlogs.map(blog => { 184 const dateObj = blog.published_at ? new Date(blog.published_at) : new Date(blog.created_at); 185 const formattedDate = dateObj.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); 186 187 // Truncate clean teaser from content 188 let teaserText = blog.excerpt; 189 if (!teaserText) { 190 const tempDiv = document.createElement('div'); 191 tempDiv.innerHTML = blog.content || ""; 192 teaserText = tempDiv.textContent || tempDiv.innerText || ""; 193 } 194 if (teaserText.length > 130) { 195 teaserText = teaserText.substring(0, 127) + "..."; 196 } 197 198 const categoryName = blog.categories ? blog.categories.name : 'News'; 199 const imageSrc = blog.featured_image_url || 'images/blog-default.jpg'; 200 const imageAlt = blog.featured_image_alt || blog.title; 201 202 // Adaptive link generation (Query strings on local file://, pretty paths on live server) 203 const detailUrl = window.location.protocol === 'file:' 204 ? `blog-detail.html?slug=${blog.slug}` 205 : `/${blog.slug}`; 206 207 return ` 208 <div class="gdlr-core-item-list ucl-blog-card-col gdlr-core-item-pdlr gdlr-core-item-mgb gdlr-core-column-20"> 209 <div class="gdlr-core-blog-modern" style="height: 100%; display: flex; flex-direction: column;"> 210 <div class="gdlr-core-blog-thumbnail gdlr-core-media-image"> 211 <a href="${detailUrl}"> 212 <img src="${imageSrc}" alt="${imageAlt.replace(/"/g, '"')}" style="width: 100%; height: 240px; object-fit: cover;" /> 213 </a> 214 </div> 215 <div class="gdlr-core-blog-modern-content" style="padding: 25px; flex: 1; display: flex; flex-direction: column; justify-content: space-between;"> 216 <div> 217 <div style="display: flex; justify-content: space-between; margin-bottom: 12px; font-size: 13px;"> 218 <span style="color: #D22B2B; font-weight: 600; text-transform: uppercase;">${categoryName}</span> 219 <span style="color: #888;">${formattedDate}</span> 220 </div> 221 <h3 class="gdlr-core-blog-title gdlr-core-skin-title" style="font-size: 18px; font-weight: 700; line-height: 1.4; margin-bottom: 12px; max-height: 52px; overflow: hidden;"> 222 <a href="${detailUrl}" style="color: #163269; transition: color 0.2s;">${blog.title}</a> 223 </h3> 224 <p style="color: #666; font-size: 14px; line-height: 1.6; max-height: 68px; overflow: hidden; margin-bottom: 20px;">${teaserText}</p> 225 </div> 226 <div style="border-top: 1px solid #f0f0f0; padding-top: 12px; display: flex; align-items: center; justify-content: space-between;"> 227 <span style="color: #192f59; font-weight: 500; font-size: 13px;">By ${blog.author_name || 'UCL Admin'}</span> 228 <a href="${detailUrl}" style="color: #D22B2B; font-weight: 600; font-size: 13px; text-decoration: none; transition: color 0.2s; display: flex; align-items: center; gap: 5px;"> 229 Read More <i class="fa fa-long-arrow-right"></i> 230 </a> 231 </div> 232 </div> 233 </div> 234 </div> 235 `; 236 }).join(''); 237 } 238 } 239 240 // Initialize instance and export to window for event handler references
241 const listInstance = new UCLBlogList(); 242 window.UCLBlogListInstance = listInstance; 243 244 // Run when DOM loads 245 if (document.readyState === 'loading') { 246 document.addEventListener('DOMContentLoaded', () => listInstance.init()); 247 } else { 248 listInstance.init(); 249 } 250})();
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.