1/** 2 * Site-wide JavaScript for 2024 Template 3 * 4 * Collection of UI functionality for the 2024 site design: 5 * - Dropdown menu toggle (no-scroll links) 6 * - Dark/light theme switching with localStorage persistence 7 * - Front page new post checker (AJAX polling) 8 * - Recent comments "show more" functionality 9 * - Tab switching for comment boxes 10 * - Long post "show more" expansion 11 * - Rolodex/blogroll AJAX refresh 12 * - Favicon recrawl button for admin 13 * - Video popup lightbox (B key to activate, Escape to close) 14 * - Fave/unfave AJAX handlers 15 * - Post-body fixups: off-site links open in a new tab, "Update:" styling 16 * 17 * @package Kottke 18 * @subpackage Templates 19 */ 20 21// Listen for a click event on links with the 'no-scroll' class 22document.addEventListener('DOMContentLoaded', function() { 23document.querySelectorAll('.no-scroll').forEach(function(link) { 24 link.addEventListener('click', function(event) { 25 // Prevent the default anchor behavior 26 event.preventDefault(); 27 // Manage the dropdown visibility by toggling classes or IDs 28 var dropdown = document.querySelector(this.getAttribute('href')); 29 if (dropdown) { 30 dropdown.style.display = (dropdown.style.display === 'none' || dropdown.style.display === '') ? 'block' : 'none'; 31 } 32 }); 33}); 34}); 35 36// Event listener for changing the theme 37document.addEventListener('DOMContentLoaded', function() { 38 var toggle = document.getElementById("theme-toggle"); 39 40 toggle.onclick = function() { 41 var currentTheme = document.documentElement.getAttribute("data-theme"); 42 var targetTheme = (currentTheme === "light") ? "dark" : "light"; 43 44 document.documentElement.setAttribute('data-theme', targetTheme); 45 localStorage.setItem('theme', targetTheme); 46 }; 47}); 48 49// Check for new posts on the front page 50function checkForUpdates(url, postId, duration) { 51 const startTime = Date.now(); 52 const slowAfter = 8 * 60 * 60 * 1000; // 8 hours in ms 53 54 function poll() { 55 fetch(`${url}?id=${postId}`) 56 .then(response => response.text()) 57 .then(data => { 58 const position = parseInt(data, 10); 59 if (isNaN(position) || position > 1) { 60 const updatesElement = document.getElementById('updates'); 61 if (updatesElement) { 62 updatesElement.style.display = 'block'; 63 } 64 return; 65 } 66 scheduleNext(); 67 }) 68 .catch(error => { 69 console.error('Error:', error); 70 scheduleNext(); 71 }); 72 } 73 74 function scheduleNext() { 75 const delay = (Date.now() - startTime) > slowAfter ? duration * 10 : duration; 76 setTimeout(poll, delay * 1000); 77 } 78 79 setTimeout(poll, duration * 1000); 80} 81 82document.addEventListener("DOMContentLoaded", () => { 83 // Use querySelectorAll to get all "Show More" buttons 84 const showMoreButtons = document.querySelectorAll(".recent-comments-more"); 85 86 showMoreButtons.forEach(button => { 87 button.addEventListener("click", function() { 88 // The parent node of the button will be the ".rcmt-tab-content" container 89 const tabContent = this.parentNode; 90 91 // Select all hidden <p> elements within this tabContent 92 const hiddenItems = tabContent.querySelectorAll('p[style*="display:none"]'); 93 94 // Loop through and show each hidden item 95 hiddenItems.forEach((item) => { 96 item.style.display = "block"; 97 }); 98 99 // Hide the "Show More" button 100 this.style.display = "none"; 101 }); 102 }); 103 }); 104 105 106// tab switching code for front page comment box 107document.addEventListener('DOMContentLoaded', (event) => { 108 // Function to open a specific tab 109 function openTab(evt, tabName) { 110 var i, tabContent, tabButtons; 111 112 // Get all elements with class="tab-content" and hide them 113 tabContent = document.getElementsByClassName('rcmt-tab-content'); 114 for (i = 0; i < tabContent.length; i++) { 115 tabContent[i].style.display = 'none'; 116 } 117 118 // Get all elements with class="tab-button" and remove the class "active" 119 tabButtons = document.getElementsByClassName('rcmt-tab-button'); 120 for (i = 0; i < tabButtons.length; i++) { 121 tabButtons[i].className = tabButtons[i].className.replace(' rcmt-active', ''); 122 } 123 124 // Show the current tab, and add an "active" class to the button that opened the tab 125 document.getElementById(tabName).style.display = 'block'; 126 evt.currentTarget.className += ' rcmt-active'; 127 } 128 129 // Attaching the event to tab buttons 130 const tabButtons = document.getElementsByClassName('rcmt-tab-button'); 131 for (let button of tabButtons) { 132 button.addEventListener('click', function(event) { 133 const tabName = this.getAttribute('data-rcmt-tab-target'); 134 openTab(event, tabName); 135 }); 136 } 137 138 }); 139 140// show more buttons for long posts 141document.addEventListener("DOMContentLoaded", () => { 142 document.querySelectorAll('.post-main').forEach(post => { 143 const showMoreBtn = post.querySelector('.show-more'); 144 145 if (post.scrollHeight > post.clientHeight) { 146 showMoreBtn.style.display = 'block'; 147 post.classList.remove('no-fade'); 148 } 149 150 showMoreBtn.addEventListener('click', function() { 151 post.style.maxHeight = post.scrollHeight + "px"; 152 this.style.display = 'none';
152 // Hide button after expanding 153 post.classList.add('no-fade'); 154 }); 155 }); 156}); 157 158// js ajax for rolodex refresh 159document.addEventListener('DOMContentLoaded', function() { 160 function refreshRolodex() { 161 const refreshBtn = document.getElementById('refresh-rolodex'); 162 const rolodexList = document.getElementById('rolodex-list'); 163 164 // Create XMLHttpRequest 165 const xhr = new XMLHttpRequest(); 166 xhr.open('GET', '/rolodex/blogroll-include.php', true); 167 xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); 168 169 xhr.onreadystatechange = function() { 170 if (xhr.readyState === 4) { 171 if (xhr.status === 200) { 172 try { 173 const response = JSON.parse(xhr.responseText); 174 175 if (response.success && response.sites) { 176 // Clear existing rows 177 const rows = rolodexList.querySelectorAll('.rolodex-row'); 178 rows.forEach(item => item.remove()); 179 180 // Add new sites 181 response.sites.forEach(site => { 182 const row = document.createElement('div'); 183 row.className = 'rolodex-row'; 184 185 let social = ''; 186 if (site.threads) social += `<a href="${site.threads}" target="_blank" title="Threads" class="rolodex-social-icon"><i class="fa-brands fa-threads"></i></a>`; 187 if (site.mastodon) social += `<a href="${site.mastodon}" target="_blank" title="Mastodon" class="rolodex-social-icon"><i class="fa-brands fa-mastodon"></i></a>`; 188 if (site.newsletter) social += `<a href="${site.newsletter}" target="_blank" title="Newsletter" class="rolodex-social-icon"><i class="fa-light fa-newspaper"></i></a>`; 189 if (site.bluesky) social += `<a href="${site.bluesky}" target="_blank" title="Bluesky" class="rolodex-social-icon"><i class="fa-brands fa-bluesky"></i></a>`; 190 if (site.instagram) social += `<a href="${site.instagram}" target="_blank" title="Instagram" class="rolodex-social-icon"><i class="fa-brands fa-instagram"></i></a>`; 191 192 row.innerHTML = ` 193 <img src="${site.favicon_url}" alt="" class="rolodex-favicon" onerror="this.src='/images/2024/rolodex-icons/default.png'"> 194 <a href="${site.display_url}" target="_blank" class="rolodex-name">${site.name}</a> 195 <a href="${site.display_url}" target="_blank" class="rolodex-url">${site.url_display}</a> 196 <span class="rolodex-social-wrap">${social}</span> 197 `; 198 rolodexList.appendChild(row); 199 }); 200 } 201 } catch (e) { 202 console.error('Error parsing JSON response:', e); 203 } 204 } else { 205 console.error('Error refreshing rolodex:', xhr.status); 206 } 207 208 } 209 }; 210 211 xhr.send(); 212 } 213 214 // Add event listener when DOM is ready 215 const refreshBtn = document.getElementById('refresh-rolodex'); 216 if (refreshBtn) { 217 refreshBtn.addEventListener('click', refreshRolodex); 218 } 219}); 220 221 222// Favicon recrawl functionality 223document.addEventListener('DOMContentLoaded', function() { 224 const recrawlButtons = document.querySelectorAll('.favicon-recrawl-btn'); 225 226 recrawlButtons.forEach(button => { 227 button.addEventListener('click', function() { 228 const entityId = this.getAttribute('data-entity-id'); 229 const entityName = this.getAttribute('data-entity-name'); 230 const originalText = this.innerHTML; 231 const faviconImg = this.closest('.entry-card').querySelector('.favicon-img'); 232 233 // Update button state 234 this.disabled = true; 235 this.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Refreshing...'; 236 237 // Make AJAX request 238 fetch(`/tools/rolodex/favicon-crawler.php?entity_id=${entity
238Id}&ajax=1&replace=1`) 239 .then(response => response.json()) 240 .then(data => { 241 if (data.success) { 242 // Force reload the favicon by adding a timestamp 243 const timestamp = new Date().getTime(); 244 const currentSrc = faviconImg.src; 245 const baseSrc = currentSrc.split('?')[0]; // Remove any existing query params 246 faviconImg.src = baseSrc + '?' + timestamp; 247 248 // Show success message 249 this.innerHTML = '<i class="fas fa-check"></i> Updated!'; 250 this.classList.remove('btn-secondary'); 251 this.classList.add('btn-success'); 252 253 // Reset button after 2 seconds 254 setTimeout(() => { 255 this.innerHTML = originalText; 256 this.classList.remove('btn-success'); 257 this.classList.add('btn-secondary'); 258 this.disabled = false; 259 }, 2000); 260 } else { 261 // Show error message 262 this.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Failed'; 263 this.classList.remove('btn-secondary'); 264 this.classList.add('btn-danger'); 265 266 // Reset button after 3 seconds 267 setTimeout(() => { 268 this.innerHTML = originalText; 269 this.classList.remove('btn-danger'); 270 this.classList.add('btn-secondary'); 271 this.disabled = false; 272 }, 3000); 273 } 274 }) 275 .catch(error => { 276 console.error('Error:', error); 277 278 // Show error message 279 this.innerHTML = '<i class="fas fa-exclamation-triangle"></i> Error'; 280 this.classList.remove('btn-secondary'); 281 this.classList.add('btn-danger'); 282 283 // Reset button after 3 seconds 284 setTimeout(() => { 285 this.innerHTML = originalText; 286 this.classList.remove('btn-danger'); 287 this.classList.add('btn-secondary'); 288 this.disabled = false; 289 }, 3000); 290 }); 291 }); 292 }); 293}); 294 295 296// code for video popup lightbox 297document.addEventListener('DOMContentLoaded', () => { 298 299 // 1) Quick helper to detect mobile 300 function isMobile() { 301 return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); 302 } 303 304 // 2) If we detect a mobile device, do nothing (i.e. no lightbox) 305 if (isMobile()) { 306 return; 307 } 308 309 // Track if B key is currently pressed 310 let fKeyPressed = false; 311 let overlaysActive = false; 312 313 const videos = document.querySelectorAll( 314 'iframe[src*="youtube.com/embed/"], ' + 315 'iframe[src*="player.vimeo.com/video/"], ' + 316 'iframe[src*="archive.org/embed/"]' 317 ); 318 319 const liteYoutubeVideos = document.querySelectorAll('lite-youtube'); 320 const liteVimeoVideos = document.querySelectorAll('lite-vimeo'); 321 322 const lightbox = document.getElementById('video-lightbox'); 323 const lightboxIframe = document.getElementById('lightbox-iframe'); 324 325 // Store all video elements and their overlays 326 const videoOverlays = []; 327 328 function createOverlay(videoElement, clickHandler) { 329 const overlay = document.createElement('div'); 330 overlay.classList.add('video-overlay'); 331 overlay.style.display = 'none'; // Hidden by default 332 333 const parent = videoElement.parentNode; 334 parent.style.position = 'relative'; 335 parent.appendChild(overlay); 336 337 overlay.addEventListener('click', clickHandler); 338 339 return overlay; 340 } 341 342 // Create overlays for all video types but keep them hidden 343 videos.forEach((video) => {
344 const overlay = createOverlay(video, () => { 345 let src = video.getAttribute('src') || ''; 346 src += src.includes('?') ? '&autoplay=1' : '?autoplay=1'; 347 lightboxIframe.setAttribute('src', src); 348 lightbox.classList.remove('hidden'); 349 }); 350 videoOverlays.push(overlay); 351 }); 352 353 liteYoutubeVideos.forEach((liteVideo) => { 354 const overlay = createOverlay(liteVideo, () => { 355 const videoId = liteVideo.getAttribute('videoid'); 356 if (videoId) { 357 const src = `https://www.youtube.com/embed/${videoId}?autoplay=1`; 358 lightboxIframe.setAttribute('src', src); 359 lightbox.classList.remove('hidden'); 360 } 361 }); 362 videoOverlays.push(overlay); 363 }); 364 365 liteVimeoVideos.forEach((liteVideo) => { 366 const overlay = createOverlay(liteVideo, () => { 367 const videoId = liteVideo.getAttribute('videoid'); 368 if (videoId) { 369 const src = `https://player.vimeo.com/video/${videoId}?autoplay=1`; 370 lightboxIframe.setAttribute('src', src); 371 lightbox.classList.remove('hidden'); 372 } 373 }); 374 videoOverlays.push(overlay); 375 }); 376 377 function showOverlays() { 378 if (!overlaysActive) { 379 videoOverlays.forEach(overlay => { 380 overlay.style.display = 'block'; 381 }); 382 overlaysActive = true; 383 } 384 } 385 386 function hideOverlays() { 387 if (overlaysActive) { 388 videoOverlays.forEach(overlay => { 389 overlay.style.display = 'none'; 390 }); 391 overlaysActive = false; 392 } 393 } 394 395 // Listen for B key press/release 396 document.addEventListener('keydown', (e) => { 397 if (e.key === 'b' || e.key === 'B') { 398 if (!fKeyPressed) { 399 fKeyPressed = true; 400 showOverlays(); 401 } 402 } 403 // Close lightbox with Escape key 404 if (e.key === 'Escape') { 405 closeLightbox(); 406 } 407 }); 408 409 document.addEventListener('keyup', (e) => { 410 if (e.key === 'b' || e.key === 'B') { 411 fKeyPressed = false; 412 hideOverlays(); 413 } 414 }); 415 416 // Also hide overlays if window loses focus (in case key gets "stuck") 417 window.addEventListener('blur', () => { 418 fKeyPressed = false; 419 hideOverlays(); 420 }); 421 422 // Close lightbox if user clicks on the dark background 423 lightbox.addEventListener('click', (e) => { 424 if (e.target === lightbox || e.target.id === 'close-lightbox') { 425 closeLightbox(); 426 } 427 }); 428 429 // Extracted close function 430 function closeLightbox() { 431 lightbox.classList.add('hidden'); 432 // Clear iframe src to stop the video from playing 433 lightboxIframe.setAttribute('src', ''); 434 } 435}); 436 437 438// Setup fave listeners 439function setupFaveListeners() { 440 441 // Utility function to send AJAX requests 442 const sendAjaxRequest = async (url, params) => { 443 try { 444 const response = await fetch(`${url}?${new URLSearchParams(params).toString()}`, { 445 method: 'GET', 446 headers: { 447 'Content-Type': 'application/json' 448 } 449 }); 450 return await response.json(); 451 } catch (error) { 452 console.error('Error in AJAX request:', error); 453 return false; 454 } 455 }; 456 457 // Toggle Fave Status Function 458 const toggleFaveStatus = async (element, entityType, entityId, currentFaveCount) => { 459 const action = element.classList.contains('faved-star') ? 'unfave' : 'fave'; 460 const params = { action, [`${entityType}-id`]: entityId }; 461 462 // Send the AJAX request 463 const success = await sendAjaxRequest('/faves/do.php', params); 464 465 if (success) { 466 const newFaveCount = action === 'fave' ? currentFaveCount + 1 : currentFaveCount - 1; 467 element.classList.toggle('faved-star'); 468 element.classList.toggle('fa-light'); 469 element.classList.toggle('fa-solid'); 470 471 // Update the text node for the fave count 472 const countNode = element.closest('a').lastChild; 473 if (countNode && countNode.nodeType === Node.TEXT_NODE) { 474 countNode.textContent = ` ${newFaveCount}`; 475 } 476 } else { 477 console.error('Failed to toggle fave status.'); 478 } 479 }; 480 481 // Rolodex sort controls 482 const rolodexList = document.getElementById('rolodex-list'); 483 if (rolodexList) { 484 document.querySelectorAll('.sort-btn').forEach(btn => { 485 btn.addEventListener('click', () => { 486 document.querySelectorAll('.sort-btn').forEach(b => b.classList.remove('active')); 487 btn.classList.add('active'); 488 489 const rows = Array.from(rolodexList.querySelectorAll('.rolodex-row')); 490 const sortMode = btn.dataset.sort; 491 492 rows.sort((a, b) => { 493 if (sortMode === 'alpha') { 494 return (a.dataset.name || '').localeCompare(b.dataset.name || '', undefined, { sensitivity: 'base' }); 495 } 496 if (sortMode === 'frequency') { 497 return (parseFloat(b.dataset.frequency) || 0) - (parseFloat(a.dataset.frequency) || 0) 498 || (a.dataset.name || '').localeCompare(b.dataset.name || '', undefined, { sensitivity: 'base' }); 499 } 500 // recent: entities with posts first, sorted by last_post_at desc 501 const aPost = a.dataset.lastPost || ''; 502 const bPost = b.dataset.lastPost || ''; 503 if (aPost && !bPost) return -1; 504 if (!aPost && bPost) return 1; 505 if (aPost && bPost) return bPost.localeCompare(aPost); 506 return (a.dataset.name || '').localeCompare(b.dataset.name || '', undefined, { sensitivity: 'base' }); 507 }); 508 509 rows.forEach(row => rolodexList.appendChild(row)); 510 }); 511 }); 512 } 513 514 // Attach click listeners to all fave links 515 document.querySelectorAll('.fave, .action-fave').forEach(link => { 516 link.addEventListener('click', event => { 517 event.preventDefault(); 518 519 // Faves are shown to everyone, but only current members can actually 520 // fave. Everyone else gets the appropriate membership modal instead 521 // (modal3 = banned, modal2 = lapsed, modal1 = logged out). A ban 522 // overrides the lapsed/logged-out states. The server also enforces 523 // this in /faves/do.php. 524 if (!window.kottkeFave || !window.kottkeFave.canParticipate) { 525 if (typeof openModal === 'function') { 526 var f = window.kottkeFave || {}; 527 var modalId = f.banned ? 'modal3' : (f.loggedIn ? 'modal2' : 'modal1'); 528 openModal(modalId); 529 } 530 return; 531 } 532 533 const actionElement = event.currentTarget; // The <a> element 534 const faveIcon = actionElement.querySelector('i'); // The <i> element 535 536 // Extract the fave count from the text node 537 const faveCountMatch = actionElement.textContent.trim().match(/\d+$/); 538 const currentFaveCount = faveCountMatch ? parseInt(faveCountMatch[0], 10) : 0; 539 540 // Determine entity type and ID 541 const entityType = actionElement.classList.contains('fave') ? 'post' : 'comment'; 542 const entityId = actionElement.dataset.postId || actionElement.dataset.commentId; 543 544 if (!entityId) { 545 console.error('Missing entity ID for fave action.'); 546 return; 547 } 548 549 // Toggle the fave status
550 toggleFaveStatus(faveIcon, entityType, entityId, currentFaveCount); 551 }); 552 }); 553} 554 555// --------------------------------------------------------------------------- 556// Post-body fixups (ported from the inline jQuery block that used to live in 557// head.php). Both run once on page load; post bodies are never swapped in by 558// AJAX. Comment bodies get the same link rule from threads.js. 559// --------------------------------------------------------------------------- 560 561// Open off-site links in post bodies in a new tab. A link stays in-tab when its 562// href is not absolute http(s) or points at kottke.org, and so do footnote 563// reference links and footnote back-links. Read the raw attribute: a.href is 564// the resolved absolute URL and would make every relative link look external. 565function retargetPostLinks() { 566 document.querySelectorAll('.post p a').forEach(function(a) { 567 var href = a.getAttribute('href') || ''; 568 var offsite = (href.startsWith('http://') || href.startsWith('https://')) && !href.includes('//kottke.org'); 569 if (offsite) { 570 a.setAttribute('target', '_blank'); 571 } else { 572 a.removeAttribute('target'); 573 } 574 }); 575 document.querySelectorAll('.post p sup a, .post .footnote a:last-of-type').forEach(function(a) { 576 a.removeAttribute('target'); 577 }); 578} 579 580// Turn "<strong>Update:</strong>" at the top of a paragraph into the styled 581// block title (see .post-update-title in main.css). 582function stylePostUpdates() { 583 document.querySelectorAll('.post p strong').forEach(function(strong) { 584 if (!strong.textContent.includes('Update:')) return; 585 strong.classList.add('post-update-title'); 586 strong.textContent = 'Update'; 587 }); 588} 589 590document.addEventListener('DOMContentLoaded', function() { 591 retargetPostLinks(); 592 stylePostUpdates(); 593});
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.