1/** 2 * Post Listings Topics Script 3 * 4 * For .ai-grand post listings with a topics menu, replaces the 5 * browser's default dropdown with a custom one and loads posts 6 * via AJAX based on the user's selected topic 7 */ 8 9(function () { 10 // Safely parse JSON arrays from data attributes 11 function parseJsonArray(value) { 12 try { 13 const parsed = JSON.parse(value || '[]'); 14 return Array.isArray(parsed) ? parsed : []; 15 } catch (error) { 16 return []; 17 } 18 } 19 20 // Ensure URLs are safe and valid before using them 21 function safeUrl(url) { 22 if (!url) return ''; 23 24 try { 25 const parsed = new URL(url, window.location.origin); 26 27 if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { 28 return parsed.href; 29 } 30 } catch (error) { 31 // Allow relative URLs starting with "/" 32 if (url.startsWith('/')) { 33 return url; 34 } 35 } 36 37 return ''; 38 } 39 40 // Generate a unique cache key based on menu item filters 41 function getTopicCacheKey(menuItem) { 42 return JSON.stringify({ 43 categories: parseJsonArray(menuItem.dataset.categories), 44 tags: parseJsonArray(menuItem.dataset.tags), 45 excludeCategories: parseJsonArray(menuItem.dataset.excludeCategories), 46 url: menuItem.dataset.url || '', 47 linkText: menuItem.dataset.linkText || '' 48 }); 49 } 50 51 // Adjust dropdown alignment if it overflows the viewport 52 function positionMoreDropdown(moreWrap, dropdown) { 53 if (!moreWrap || !dropdown) return; 54 55 const gutter = 10; 56 const rect = dropdown.getBoundingClientRect(); 57 58 // If dropdown is too close to left edge, force left alignment 59 if (rect.left < gutter) { 60 moreWrap.classList.add('align-left'); 61 } 62 } 63 64 function createPostImageLink(post) { 65 if (!post.image_src) { 66 return null; 67 } 68 69 const safePermalink = safeUrl(post.permalink); 70 const safeImageSrc = safeUrl(post.image_src); 71 72 if (!safePermalink || !safeImageSrc) { 73 return null; 74 } 75 76 const link = document.createElement('a'); 77 link.href = safePermalink; 78 79 const image = document.createElement('img'); 80 image.src = safeImageSrc; 81 image.alt = post.image_alt || post.title || ''; 82 image.loading = 'lazy'; 83 84 link.appendChild(image); 85 86 return link; 87 } 88 89 // Create post title element 90 function createPostTitle(post) { 91 const heading = document.createElement('h3'); 92 heading.className = 'item-title'; 93 94 const link = document.createElement('a'); 95 link.href = safeUrl(post.permalink); 96 link.textContent = post.title || '(Untitled)'; 97 98 heading.appendChild(link); 99 100 return heading; 101 } 102 103 // Create post date element 104 function createPostDate(post) { 105 if (!post.date_display || !post.date_iso) { 106 return null; 107 } 108 109 const time = document.createElement('time'); 110 time.dateTime = post.date_iso; 111 time.textContent = post.date_display; 112 113 return time; 114 } 115 116 // Spotlight Feature layout post 117 function createFeaturePost(post) { 118 const item = document.createElement('div'); 119 item.className = 'post-item feature'; 120 121 const imageLink = createPostImageLink(post); 122 if (imageLink) { 123 item.appendChild(imageLink); 124 } 125 126 const content = document.createElement('div'); 127 content.className = 'content-section'; 128 129 content.appendChild(createPostTitle(post)); 130 131 const date = createPostDate(post); 132 if (date) { 133 content.appendChild(date); 134 } 135 136 item.appendChild(content); 137 138 return item; 139 } 140 141 // Spotlight Stack layout post 142 function createStackPost(post) { 143 const item = document.createElement('div'); 144 item.className = 'post-item stack'; 145 146 const imageSection = document.createElement('div'); 147 imageSection.className = 'image-section'; 148 149 const imageLink = createPostImageLink(post); 150 if (imageLink) { 151 imageSection.appendChild(imageLink); 152 } 153 154 item.appendChild(imageSection); 155 156 const content = document.createElement('div'); 157 content.className = 'content-section'; 158 159 content.appendChild(createPostTitle(post)); 160 161 const date = createPostDate(post); 162 if (date) { 163 content.appendChild(date); 164 } 165 166 item.appendChild(content); 167 168 return item; 169 } 170 171 // Default layout post 172 function createStandardPost(post) { 173 const item = document.createElement('div'); 174 item.className = 'post-item'; 175 176 const imageSection = document.createElement('div'); 177 imageSection.className = 'image-section'; 178 179 const imageLink = createPostImageLink(post); 180 if (imageLink) { 181 imageSection.appendChild(imageLink); 182 } 183 184 item.appendChild(imageSection); 185 186 const content = document.createElement('div'); 187 content.className = 'content-section'; 188 189 content.appendChild(createPostTitle(post)); 190 191 const date = createPostDate(post); 192 if (date) { 193 content.appendChild(date); 194 } 195 196 item.appendChild(content); 197 198 return item; 199 } 200 201 // Render posts into container 202 function renderPostsShell(postsShell, posts) { 203 if (!postsShell) return; 204 205 postsShell.replaceChildren();
206 207 // Show empty state if no posts 208 if (!Array.isArray(posts) || !posts.length) { 209 const empty = document.createElement('p'); 210 empty.textContent = 'No posts found.'; 211 postsShell.appendChild(empty); 212 return; 213 } 214 215 const blockContent = document.createElement('div'); 216 blockContent.className = 'block-content'; 217 218 posts.forEach(function (post) { 219 if (!post) return; 220 221 // Choose layout type 222 if (post.feature) { 223 blockContent.appendChild(createFeaturePost(post)); 224 } else if (post.stack) { 225 blockContent.appendChild(createStackPost(post)); 226 } else { 227 blockContent.appendChild(createStandardPost(post)); 228 } 229 }); 230 231 postsShell.appendChild(blockContent); 232 } 233 234 // Main click handler (event delegation) 235 document.addEventListener('click', function (event) { 236 237 // Handle "More" toggle click (mobile only) 238 const moreToggle = event.target.closest('.ai-grand-instance .menu-more-toggle'); 239 240 if (moreToggle) { 241 // Skip if hover-capable device (desktop) 242 if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) return; 243 244 const moreWrap = moreToggle.closest('.menu-more'); 245 if (!moreWrap) return; 246 247 const isOpen = moreWrap.classList.contains('is-open'); 248 249 if (isOpen) { 250 closeMoreMenu(moreWrap); 251 } else { 252 openMoreMenu(moreWrap); 253 } 254 255 return; 256 } 257 258 // Handle menu item click 259 const menuItem = event.target.closest('.ai-grand-instance .menu-item:not(.menu-more-toggle)'); 260 261 // Click outside menu â close dropdowns 262 if (!menuItem) { 263 const clickedInsideMore = event.target.closest('.ai-grand-instance .menu-more'); 264 265 if (!clickedInsideMore) { 266 document.querySelectorAll('.ai-grand-instance .menu-more').forEach(function (menu) { 267 menu.classList.remove('is-open'); 268 269 const toggle = menu.querySelector('.menu-more-toggle'); 270 const panel = menu.querySelector('.menu-more-dropdown'); 271 272 if (toggle) { 273 toggle.classList.remove('active'); 274 toggle.setAttribute('aria-expanded', 'false'); 275 } 276 277 if (panel) { 278 panel.hidden = true; 279 } 280 }); 281 } 282 283 return; 284 } 285 286 const instance = menuItem.closest('.ai-grand-instance'); 287 if (!instance) return; 288 289 // Prevent duplicate requests 290 if (instance.classList.contains('is-loading')) return; 291 if (menuItem.classList.contains('active')) return; 292 293 // Initialize cache 294 if (!instance._topicCache) { 295 instance._topicCache = {}; 296 } 297 298 const allMenuItems = instance.querySelectorAll('.menu-item'); 299 const postsShell = instance.querySelector('.posts-shell'); 300 const morePostsLink = instance.querySelector('.action .link'); 301 302 // Update active state 303 allMenuItems.forEach(function (item) { 304 item.classList.remove('active'); 305 }); 306 307 menuItem.classList.add('active'); 308 309 // Close all "more" menus 310 instance.querySelectorAll('.menu-more').forEach(function (menu) { 311 menu.classList.remove('is-open'); 312 313 const toggle = menu.querySelector('.menu-more-toggle'); 314 const panel = menu.querySelector('.menu-more-dropdown'); 315 316 if (toggle) { 317 toggle.classList.remove('active'); 318 toggle.setAttribute('aria-expanded', 'false'); 319 } 320 321 if (panel) { 322 panel.hidden = true; 323 } 324 }); 325 326 // Update "See more posts" link 327 if (morePostsLink) { 328 morePostsLink.setAttribute('href', safeUrl(menuItem.dataset.url)); 329 morePostsLink.textContent = menuItem.dataset.linkText || 'See More Posts'; 330 } 331 332 const cacheKey = getTopicCacheKey(menuItem); 333 334 // Use cached posts if available 335 if (instance._topicCache[cacheKey] && postsShell) { 336 renderPostsShell(postsShell, instance._topicCache[cacheKey]); 337 return; 338 } 339 340 // Build AJAX request 341 const formData = new FormData(); 342 formData.append('action', 'ai_spotlight_posts_filter'); 343 formData.append('nonce', instance.dataset.nonce); 344 formData.append('total', instance.dataset.total); 345 formData.append('orderby', instance.dataset.orderby); 346 formData.append('order', instance.dataset.order); 347 formData.append('show_date', instance.dataset.showDate); 348 formData.append('layout_class', instance.dataset.layoutClass || 'feature'); 349 350 const categories = parseJsonArray(menuItem.dataset.categories);
351 categories.forEach(function (category) { 352 formData.append('categories[]', category); 353 }); 354 355 const tags = parseJsonArray(menuItem.dataset.tags); 356 tags.forEach(function (tag) { 357 formData.append('tags[]', tag); 358 }); 359 360 const excludeCategories = parseJsonArray(menuItem.dataset.excludeCategories); 361 excludeCategories.forEach(function (category) { 362 formData.append('exclude_categories[]', category); 363 }); 364 365 // Cancel previous request if needed 366 if (instance._topicRequestController) { 367 instance._topicRequestController.abort(); 368 } 369 370 instance._topicRequestController = new AbortController(); 371 372 instance.classList.add('is-loading'); 373 374 // Fetch posts 375 fetch(instance.dataset.ajaxUrl, { 376 method: 'POST', 377 body: formData, 378 credentials: 'same-origin', 379 signal: instance._topicRequestController.signal 380 }) 381 .then(function (response) { 382 if (!response.ok) { 383 throw new Error('Request failed with status ' + response.status); 384 } 385 386 return response.json(); 387 }) 388 .then(function (response) { 389 390 if (response && response.success && response.data && Array.isArray(response.data.posts) && postsShell) { 391 instance._topicCache[cacheKey] = response.data.posts; 392 renderPostsShell(postsShell, response.data.posts); 393 } else { 394 console.error('AJAX unexpected response shape:', response); 395 } 396 397 instance.classList.remove('is-loading'); 398 }) 399 .catch(function (error) { 400 if (error.name === 'AbortError') { 401 instance.classList.remove('is-loading'); 402 return; 403 } 404 405 console.error('Posts request failed:', error); 406 instance.classList.remove('is-loading'); 407 }); 408 }); 409 410 // Open "More" dropdown 411 function openMoreMenu(moreWrap) { 412 const toggle = moreWrap.querySelector('.menu-more-toggle'); 413 const dropdown = moreWrap.querySelector('.menu-more-dropdown'); 414 const instance = moreWrap.closest('.ai-grand-instance'); 415 416 if (!toggle || !dropdown || !instance) return; 417 418 // Close other menus first 419 instance.querySelectorAll('.menu-more').forEach(function (menu) { 420 menu.classList.remove('is-open'); 421 422 const otherToggle = menu.querySelector('.menu-more-toggle'); 423 const otherDropdown = menu.querySelector('.menu-more-dropdown'); 424 425 if (otherToggle) { 426 otherToggle.classList.remove('active'); 427 otherToggle.setAttribute('aria-expanded', 'false'); 428 } 429 430 if (otherDropdown) { 431 otherDropdown.hidden = true; 432 } 433 434 // reset alignment state for other menus 435 menu.classList.remove('align-left'); 436 }); 437 438 // reset alignment before measuring 439 moreWrap.classList.remove('align-left'); 440 441 moreWrap.classList.add('is-open'); 442 toggle.classList.add('active'); 443 toggle.setAttribute('aria-expanded', 'true'); 444 dropdown.hidden = false; 445 446 requestAnimationFrame(function () { 447 positionMoreDropdown(moreWrap, dropdown); 448 }); 449 } 450 451 // Close "More" dropdown 452 function closeMoreMenu(moreWrap) { 453 const toggle = moreWrap.querySelector('.menu-more-toggle'); 454 const dropdown = moreWrap.querySelector('.menu-more-dropdown'); 455 456 if (!toggle || !dropdown) return; 457 458 moreWrap.classList.remove('is-open'); 459 toggle.classList.remove('active'); 460 toggle.setAttribute('aria-expanded', 'false'); 461 dropdown.hidden = true; 462 } 463 464 // Desktop hover behavior 465 if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) { 466 document.querySelectorAll('.ai-grand-instance .menu-more').forEach(function (moreWrap) { 467 let leaveTimer; 468 469 moreWrap.addEventListener('mouseenter', function () { 470 clearTimeout(leaveTimer); 471 openMoreMenu(moreWrap); 472 }); 473 474 moreWrap.addEventListener('mouseleave', function () { 475 leaveTimer = setTimeout(function () { 476 closeMoreMenu(moreWrap); 477 }, 120); 478 }); 479 }); 480 } 481 482 // Reposition dropdowns on resize 483 window.addEventListener('resize', function () { 484 document.querySelectorAll('.ai-grand-instance .menu-more.is-open').forEach(function (moreWrap) { 485 const dropdown = moreWrap.querySelector('.menu-more-dropdown'); 486 487 if (dropdown) { 488 positionMoreDropdown(moreWrap, dropdown); 489 } 490 }); 491 }); 492})();
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.