PageSourceSearch

https://kottke.org/templates/2024/js/site.js?v=17

js kottke.org collected 2026-09-24 15:17:17 UTC 23,793 bytes, 593 lines download raw bytes

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.