PageSourceSearch

https://gvolpe.com/js/main.js

js gvolpe.com collected 2026-09-25 19:53:36 UTC 51,764 bytes, 1,663 lines download raw bytes

1// LofiCode Main JavaScript
2
3(function() {
4  "use strict";
5
6  // Theme toggle functionality with auto-detection
7  const themeToggle = document.querySelector(".theme-toggle");
8  const htmlElement = document.documentElement;
9
10  // Auto-detect theme preference
11  function getInitialTheme() {
12    const savedTheme = localStorage.getItem("theme");
13    if (savedTheme) {
14      return savedTheme;
15    }
16
17    // Use system preference if no saved theme
18    if (
19      window.matchMedia &&
20      window.matchMedia("(prefers-color-scheme: dark)").matches
21    ) {
22      return "dark";
23    }
24
25    return "light";
26  }
27
28  // Set initial theme
29  const currentTheme = getInitialTheme();
30  htmlElement.setAttribute("data-theme", currentTheme);
31
32  // Update button icon and text based on theme
33  function updateThemeIcon() {
34    const isDark = htmlElement.getAttribute("data-theme") === "dark";
35    if (themeToggle) {
36      themeToggle.innerHTML = isDark ? "☀️" : "🌙";
37    }
38  }
39
40  // Listen for system theme changes
41  if (window.matchMedia) {
42    window
43      .matchMedia("(prefers-color-scheme: dark)")
44      .addEventListener("change", (e) => {
45        // Only auto-switch if user hasn't manually set a preference
46        if (!localStorage.getItem("theme")) {
47          const newTheme = e.matches ? "dark" : "light";
48          htmlElement.setAttribute("data-theme", newTheme);
49          updateThemeIcon();
50          syncRemark42Theme();
51        }
52      });
53  }
54
55  if (themeToggle) {
56    updateThemeIcon();
57
58    themeToggle.addEventListener("click", () => {
59      const currentTheme = htmlElement.getAttribute("data-theme");
60      const newTheme = currentTheme === "dark" ? "light" : "dark";
61
62      htmlElement.setAttribute("data-theme", newTheme);
63      localStorage.setItem("theme", newTheme);
64      updateThemeIcon();
65      syncRemark42Theme();
66    });
67  }
68
69  // Reading progress (for blog posts)
70  const readingProgress = document.querySelector(".reading-progress");
71  const article = document.querySelector(".post-content");
72
73  if (readingProgress && article) {
74    const overlay = document.querySelector("#post-overlay-id");
75    const content = document.querySelector("#post-overlay-content-id");
76    if (!overlay || !content) return;
77
78    const articleTop = article.offsetTop;
79    const articleHeight = article.offsetHeight;
80    const windowHeight = window.innerHeight;
81
82    function updateReadingProgress() {
83      const scrolled = content.scrollTop;
84      var factor = 0;
85
86      if (scrolled < 10) {
87        factor = 0.1;
88      } else if (scrolled < 100) {
89        factor = 0.05;
90      } else if (scrolled < 200) {
91        factor = 0.02;
92      }
93
94      const progress = Math.max(
95        0,
96        Math.min(
97          100,
98          (((scrolled - articleTop + windowHeight) / articleHeight) - factor) * 100
99        )
100      );
101
102      readingProgress.style.width = progress + "%";
103    }
104
105    overlay.addEventListener("scroll", updateReadingProgress, true);
106  }
107
108  // Copy code functionality for shortcodes
109  document.querySelectorAll(".copy-btn").forEach((btn) => {
110    btn.addEventListener("click", async (e) => {
111      const codeContent = e.target.parentElement.nextElementSibling.textContent;
112
113      try {
114        await navigator.clipboard.writeText(codeContent);
115        btn.textContent = "Copied!";
116        btn.style.background = "rgba(0, 255, 0, 0.3)";
117
118        setTimeout(() => {
119          btn.textContent = "Copy";
120          btn.style.background = "rgba(255, 255, 255, 0.2)";
121        }, 2000);
122      } catch (err) {
123        btn.textContent = "Failed";
124        setTimeout(() => {
125          btn.textContent = "Copy";
126        }, 2000);
127      }
128    });
129  });
130
131  // Enhanced code fence functionality for markdown
132  function initializeCodeFences() {
133    const highlights = document.querySelectorAll(".highlight");
134
135    highlights.forEach((highlight) => {
136      // Extract language from class names
137      const code = highlight.querySelector("code");
138
139      if (code) {
140        // Look for language class (e.g., language-html, language-javascript)
141        const classList = Array.from(code.classList);
142        const langClass = classList.find((cls) => cls.startsWith("language-"));
143        const hlClass = Array.from(highlight.classList);
144
145        if (hlClass && hlClass.includes("unison-lang")) {
146          highlight.setAttribute("data-lang", "unison");
147        } else if (hlClass && hlClass.includes("dhall-lang")) {
148          highlight.setAttribute("data-lang", "dhall");
149        } else if (langClass) {
150          const lang = langClass.replace("language-", "");
151          highlight.setAttribute("data-lang", lang);
152        }
153      }
154
155      // Add copy functionality to markdown code fences
156      highlight.addEventListener("click", async (e) => {
157        // Check if click is on the copy button area (pseudo-element)
158        const rect = highlight.getBoundingClientRect();
159        const clickX = e.clientX - rect.left;
160        const clickY = e.clientY - rect.top;
161
162        // Copy button is positioned at center-right of header (header is ~50px tall)
163        if (clickX > rect.width - 80 && clickY < 50) {
164          e.preventDefault();
165
166          const code = highlight.querySelector("code");
167          if (code) {
168            try {
169              // Extract clean code content without line numbers
170              let codeText = code.textContent;
171
172              // Remove line numbers if they exist
173              // Hugo often generates line numbers in various formats
174              codeText = codeText
175                // Remove numbered lines (e.g., "1 ", "2 ", etc. at start of lines)
176                .replace(/^\s*\d+\s+/gm, '')
177                // Remove line number spans or elements
178                .replace(/^\s*\d+\s*/gm, '')
179                // Clean up any extra whitespace
180                .trim();
181
182              await navigator.clipboard.writeText(codeText);
183
184              // Visual feedback - temporarily change the pseudo-element content
185              highlight.setAttribute("data-copied", "true");
186
187              setTimeout(() => {
188                highlight.removeAttribute("data-copied");
189              }, 2000);
190            } catch (err) {
191              console.log("Copy failed:", err);
192            }
193          }
194        }
195      });
196    });
197  }
198
199  // Initialize code fences when DOM is ready
200  initializeCodeFences();
201
202  // Table of contents generation and highlighting
203  function generateTOC() {
204    const tocContent = document.getElementById("toc-content");
205    const headings = document.querySelectorAll(".post-body h2, .post-body h3");
206
207    if (!tocContent || headings.length === 0) return;
208
209    const tocList = document.createElement("ul");
210
211    headings.forEach((heading, index) => {
212      // Add ID to heading if it doesn't have one
213      if (!heading.id) {
214        heading.id = `heading-${index}`;
215      }
216
217      const li = document.createElement("li");
218      const a = document.createElement("a");
219      a.href = `#${heading.id}`;
220      a.textContent = heading.textContent;
221      a.addEventListener("click", (e) => {
222        e.preventDefault();
223        const headerHeight = document.querySelector('.site-header')?.offsetHeight || 80;
224        const targetPosition = heading.getBoundingClientRect().top + window.pageYOffset - headerHeight - 20;
225        window.scrollTo({
226          top: targetPosition,
227          behavior: "smooth"
228        });
229      });
230
231      li.appendChild(a);
232      tocList.appendChild(li);
233    });
234
235    tocContent.appendChild(tocList);
236  }
237
238  function updateTOC() {
239    const headings = document.querySelectorAll(".post-body h2, .post-body h3");
240    const tocLinks = document.querySelectorAll("#toc-content a");
241
242    if (headings.length === 0 || tocLinks.length === 0) return;
243
244    let current = 0;
245    headings.forEach((heading, i) => {
246      if (heading.getBoundingClientRect().top <= 100) {
247        current = i;
248      }
249    });
250
251    tocLinks.forEach((link, i) => {
252      link.classList.toggle("active", i === current);
253    });
254  }
255
256  // Generate TOC if we're on a blog post
257  if (document.querySelector(".post-body")) {
258    generateTOC();
259    const overlay = document.querySelector("#post-overlay-id");
260    overlay.addEventListener("scroll", updateTOC, true);
261    updateTOC(); // Initial call
262  }
263
264  // Tag filtering (for homepage)
265  const filterTags = document.querySelectorAll(".filter-tags .tag");
266  filterTags.forEach((tag) => {
267    tag.addEventListener("click", () => {
268      // Remove active from all tags
269      filterTags.forEach((t) => t.classList.remove("active"));
270      // Add active to clicked tag
271      tag.classList.add("active");
272
273      const tagName = tag.dataset.tag || tag.textContent.toLowerCase();
274      const posts = document.querySelectorAll(".post-item");
275
276      if (tagName === "all" || tag.textContent === "All") {
277        // Show all posts
278        posts.forEach((post) => {
279          post.style.display = "grid";
280        });
281      } else {
282        // Filter posts by tag
283        posts.forEach((post) => {
284          const postTags = Array.from(
285            post.querySelectorAll(".post-tag-vaporwave")
286          ).map((tagEl) => tagEl.textContent.toLowerCase());
287
288          if (postTags.some((postTag) => postTag.includes(tagName))) {
289            post.style.display = "grid";
290          } else {
291            post.style.display = "none";
292          }
293        });
294      }
295    });
296  });
297
298  // Smooth scroll for anchor links
299  document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
300    anchor.addEventListener("click", function(e) {
301      e.preventDefault();
302      const target = document.querySelector(this.getAttribute("href"));
303      if (target) {
304        target.scrollIntoView({
305          behavior: "smooth",
306          block: "start",
307        });
308      }
309    });
310  });
311
312  // Sliding Search Form functionality
313  const searchToggle = document.querySelector(".search-toggle");
314  const searchForm = document.getElementById("searchForm");
315  const searchInput = document.getElementById("searchInput");
316  const searchResults = document.getElementById("searchResults");
317  const searchClose = document.querySelector(".search-close");
318  const searchSubmit = document.querySelector(".search-submit");
319
320  let searchData = [];
321  let isSearchOpen = false;
322
323  // Load search data (posts) - works on all pages
324  async function loadSearchData() {
325    if (searchData.length === 0) {
326      let posts = [];
327
328      if (window.spa && window.spa.pagesData) {
329        posts = window.spa.pagesData;
330      }
331
332      if (posts.length > 0) {
333        searchData = Array.from(posts).map((post) => {
334          const tags = post.tags || [];
335          const excerpt = plainText(post.excerpt || post.summary);
336
337          return {
338            title: post.title,
339            url: post.url,
340            excerpt,
341            searchText: plainText(`${post.title} ${excerpt} ${post.summary || ""}`),
342            tags,
343            date: post.date,
344            element: post,
345          };
346        });
347      }
348    }
349  }
350
351  // Toggle search form
352  function toggleSearch() {
353    isSearchOpen = !isSearchOpen;
354
355    if (isSearchOpen) {
356      searchForm.classList.add("active");
357      setTimeout(() => {
358        searchInput.focus();
359      }, 300);
360    } else {
361      searchForm.classList.remove("active");
362      searchInput.value = "";
363      searchResults.innerHTML = "";
364      searchResults.classList.remove("has-results");
365      // Reset post visibility
366      resetPostVisibility();
367    }
368
369    loadSearchData();
370  }
371
372  // Reset post visibility and filters
373  function resetPostVisibility() {
374    const posts = document.querySelectorAll(".post-item");
375    posts.forEach((post) => {
376      post.style.display = "grid";
377      post.style.outline = "none";
378    });
379
380    // Reset filter tags
381    const filterTags = document.querySelectorAll(".filter-tags .tag");
382    filterTags.forEach((tag) => tag.classList.remove("active"));
383    const allTag =
384      document.querySelector('.filter-tags .tag[data-tag="all"]') ||
385      document.querySelector(".filter-tags .tag:first-child");
386    if (allTag) allTag.classList.add("active");
387  }
388
389  // Perform search
390  function performSearch(query) {
391    if (!query.trim()) {
392      searchResults.innerHTML = "";
393      searchResults.classList.remove("has-results");
394      resetPostVisibility();
395      return;
396    }
397
398    const searchLower = query.toLowerCase();
399    const results = searchData.filter((post) => {
400      return (
401        post.searchText.toLowerCase().includes(searchLower) ||
402        post.tags.some((tag) => tag.toLowerCase().includes(searchLower))
403      );
404    });
405
406    displaySearchResults(results, query);
407    highlightSearchResults(results);
408  }
409
410  // Display search results
411  function displaySearchResults(results, query) {
412    if (results.length === 0) {
413      searchResults.innerHTML = `
414        <div class="search-no-results">
415          No article found for "${query}". Try different keywords or browse by tags.
416        </div>
417      `;
418    } else {
419      const resultsHTML = results
420        .map(
421          (post) => `
422        <div class="search-result-item" onclick="handleSearchResultClick('${post.url
423            }')">
424          <div class="search-result-title">${highlightText(
425              post.title,
426              query
427            )}</div>
428          <div class="search-result-excerpt">${highlightText(
429              truncateText(post.excerpt, 120),
430              query
431            )}</div>
432          <div class="search-result-meta">
433            <span>${post.date}</span>
434            <span>${post.tags.join(", ")}</span>
435          </div>
436        </div>
437      `
438        )
439        .join("");
440
441      searchResults.innerHTML = resultsHTML;
442    }
443
444    searchResults.classList.add("has-results");
445  }
446
447  // Handle search result clicks with SPA navigation
448  function handleSearchResultClick(url) {
449    // Close search first
450    toggleSearch();
451
452    // Use SPA navigation if available
453    if (window.spa && url.startsWith("/blog/")) {
454      const slug = url.split("/blog/")[1].replace("/", "");
455      window.spa.showPost(slug, true);
456    } else {
457      // Fallback to regular navigation
458      window.location.href = url;
459    }
460  }
461
462  // Make handleSearchResultClick globally accessible
463  window.handleSearchResultClick = handleSearchResultClick;
464
465  // Highlight search results on page (only works on homepage)
466  function highlightSearchResults(results) {
467    const posts = document.querySelectorAll(".post-item");
468
469    // Only highlight if we're on a page with post items (homepage)
470    if (posts.length > 0) {
471      const resultUrls = new Set(results.map((r) => r.url));
472
473      posts.forEach((post) => {
474        const titleLink = post.querySelector(".post-title-vaporwave a");
475        if (titleLink && resultUrls.has(titleLink.href)) {
476          post.style.display = "grid";
477          post.style.outline = "2px solid var(--accent-primary)";
478        } else {
479          post.style.display = "none";
480        }
481      });
482    }
483  }
484
485  // Highlight text matches
486  function highlightText(text, query) {
487    if (!query.trim()) return text;
488
489    const regex = new RegExp(`(${escapeRegex(query)})`, "gi");
490    return text.replace(
491      regex,
492      '<mark style="background: var(--accent-primary); color: white; padding: 0.1rem 0.2rem; border-radius: 3px;">$1</mark>'
493    );
494  }
495
496  // Escape regex special characters
497  function escapeRegex(string) {
498    return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
499  }
500
501  // Truncate text
502  function truncateText(text, maxLength) {
503    if (text.length <= maxLength) return text;
504    return text.substr(0, maxLength).trim() + "...";
505  }
506
507  function plainText(value) {
508    const element = document.createElement("div");
509    element.innerHTML = value || "";
510    return element.textContent || element.innerText || "";
511  }
512
513  function escapeHtml(value) {
514    return String(value || "").replace(/[&<>"']/g, (char) => ({
515      "&": "&amp;",
516      "<": "&lt;",
517      ">": "&gt;",
518      '"': "&quot;",
519      "'": "&#39;",
520    })[char]);
521  }
522
523  function remark42ThreadURL(post) {
524    const config = window.remark42SiteConfig || {};
525    const base = (config.page_url_base || window.location.origin).replace(/\/$/, "");
526    const path = new URL(post.url || `/blog/${post.slug}/`, window.location.origin).pathname;
527    return `${base}${path}`;
528  }
529
530  function currentRemark42Theme() {
531    return htmlElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
532  }
533
534  function cssVar(name) {
535    return getComputedStyle(htmlElement).getPropertyValue(name).trim();
536  }
537
538  function rgbTriplet(value, fallback) {
539    if (!value) {
540      return fallback;
541    }
542
543    const rgb = value.match(/rgba?\(([^)]+)\)/);
544    if (rgb) {
545      return rgb[1].split(",").slice(0, 3).map((part) => parseInt(part, 10)).join(",");
546    }
547
548    const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
549    if (hex) {
550      const raw = hex[1].length === 3
551        ? hex[1].split("").map((char) => char + char).join("")
552        : hex[1];
553      return [
554        parseInt(raw.slice(0, 2), 16),
555        parseInt(raw.slice(2, 4), 16),
556        parseInt(raw.slice(4, 6), 16),
557      ].join(",");
558    }
559
560    return fallback;
561  }
562
563  function remark42Colors() {
564    const isDark = currentRemark42Theme() === "dark";
565    const accentPrimary = cssVar("--accent-primary");
566    const accentSecondary = cssVar("--accent-secondary");
567    const accentTertiary = cssVar("--accent-tertiary");
568    const textPrimary = cssVar("--text-primary");
569    const textSecondary = cssVar("--text-secondary");
570    const textMuted = cssVar("--text-muted");
571    const bgCard = cssVar("--bg-card");
572    const bgSecondary = cssVar("--bg-secondary");
573    const border = cssVar("--border-color");
574    const codeBg = cssVar("--code-bg");
575    const codeText = cssVar("--code-text");
576    const formSurface = isDark ? "#2A194B" : bgCard;
577    const formSurfaceMuted = isDark ? "#35235D" : bgSecondary;
578    const formText = isDark ? "#F4F0FF" : textPrimary;
579    const formMutedText = isDark ? "#D7CFF2" : textSecondary;
580
581    return {
582      "--primary-color": rgbTriplet(accentPrimary, "255,0,107"),
583      "--primary-brighter-color": rgbTriplet(accentTertiary, "255,127,80"),
584      "--primary-darker-color": rgbTriplet(accentPrimary, "230,0,95"),
585      "--primary-text-color": rgbTriplet(textPrimary, "45,27,78"),
586      "--secondary-text-color": rgbTriplet(textSecondary, "90,75,124"),
587      "--secondary-darker-text-color": rgbTriplet(textMuted, "139,123,168"),
588      "--primary-background-color": rgbTriplet(bgCard, "255,255,255"),
589      "--line-color": border,
590      "--line-brighter-color": border,
591      "--color4": bgSecondary,
592      "--color5": isDark ? formText : "#EEEEEE",
593      "--color6": bgCard,
594      "--color7": formSurfaceMuted,
595      "--color8": formSurface,
596      "--color9": accentSecondary,
597      "--color15": accentPrimary,
598      "--color16": border,
599      "--color19": formSurfaceMuted,
600      "--color20": formMutedText,
601      "--color21": bgSecondary,
602      "--color22": formSurface,
603      "--color23": formSurfaceMuted,
604      "--color24": border,
605      "--color31": border,
606      "--color33": accentTertiary,
607      "--color35": textSecondary,
608      "--color37": textMuted,
609      "--color40": border,
610      "--color41": textPrimary,
611      "--color45": border,
612      "--color46": border,
613      "--color47": "rgba(" + rgbTriplet(accentPrimary, "255,0,10
6137") + ",.3)",
614      "--color48": "rgba(" + rgbTriplet(accentPrimary, "255,0,107") + ",.45)",
615      "--chroma-bg": codeBg,
616      "--chroma-base": codeText,
617    };
618  }
619
620  let remark42Instance = null;
621  let remark42Pending = null;
622  let remark42ActiveNode = null;
623  let remark42ActiveRoot = null;
624
625  function ensureRemark42Script(host) {
626    if (window.REMARK42 || document.getElementById("remark42-embed-script")) {
627      return;
628    }
629
630    const script = document.createElement("script");
631    let extension = ".js";
632    script.id = "remark42-embed-script";
633    if ("noModule" in script) {
634      script.type = "module";
635      extension = ".mjs";
636    } else {
637      script.async = true;
638    }
639    script.defer = true;
640    script.src = `${host}/web/embed${extension}`;
641    script.addEventListener("load", mountPendingRemark42);
642    (document.head || document.body).appendChild(script);
643  }
644
645  function mountPendingRemark42() {
646    if (!remark42Pending || !window.REMARK42 || !window.REMARK42.createInstance) {
647      return;
648    }
649
650    if (remark42Instance && remark42Instance.destroy) {
651      remark42Instance.destroy();
652    } else if (window.REMARK42.destroy) {
653      window.REMARK42.destroy();
654    }
655
656    remark42Instance = window.REMARK42.createInstance({
657      node: remark42Pending.node,
658      ...remark42Pending.config,
659    });
660    remark42ActiveNode = remark42Pending.node;
661    remark42ActiveRoot = remark42Pending.root;
662    remark42Pending = null;
663  }
664
665  function destroyRemark42Comments() {
666    if (remark42Instance && remark42Instance.destroy) {
667      remark42Instance.destroy();
668    } else if (window.REMARK42 && window.REMARK42.destroy) {
669      window.REMARK42.destroy();
670    }
671
672    remark42Instance = null;
673    remark42Pending = null;
674    remark42ActiveNode = null;
675    remark42ActiveRoot = null;
676  }
677
678  function syncRemark42Theme() {
679    if (!remark42ActiveRoot) {
680      return;
681    }
682
683    const root = remark42ActiveRoot;
684    destroyRemark42Comments();
685    initRemark42Comments(root);
686  }
687
688  function initRemark42Comments(root = document) {
689    const section = root.matches && root.matches("[data-remark42-comments]")
690      ? root
691      : root.querySelector("[data-remark42-comments]");
692    const siteConfig = window.remark42SiteConfig || {};
693    if (!section || !siteConfig.host || !siteConfig.site_id) {
694      return;
695    }
696
697    const node = section.querySelector("#remark42");
698    if (!node || remark42ActiveNode === node) {
699      return;
700    }
701
702    const config = {
703      host: siteConfig.host,
704      site_id: siteConfig.site_id,
705      url: section.dataset.remark42Url,
706      page_title: section.dataset.remark42Title,
707      components: ["embed"],
708      theme: currentRemark42Theme(),
709      __colors__: remark42Colors(),
710      no_footer: true,
711      show_rss_subscription: false,
712    };
713
714    remark42Pending = { node, config, root: section };
715    window.remark_config = config;
716    ensureRemark42Script(config.host);
717    mountPendingRemark42();
718  }
719
720  window.addEventListener("REMARK42::ready", mountPendingRemark42);
721
722  // Event listeners for search
723  if (searchToggle) {
724    searchToggle.addEventListener("click", toggleSearch);
725  }
726
727  if (searchClose) {
728    searchClose.addEventListener("click", toggleSearch);
729  }
730
731  if (searchInput) {
732    // Real-time search as user types
733    let searchTimeout;
734    searchInput.addEventListener("input", (e) => {
735      clearTimeout(searchTimeout);
736      searchTimeout = setTimeout(() => {
737        performSearch(e.target.value);
738      }, 300); // Debounce search
739    });
740
741    // Handle Enter key
742    searchInput.addEventListener("keydown", (e) => {
743      if (e.key === "Enter") {
744        e.preventDefault();
745        performSearch(searchInput.value);
746      }
747
748      // Close search with Escape
749      if (e.key === "Escape") {
750        toggleSearch();
751      }
752    });
753  }
754
755  if (searchSubmit) {
756    searchSubmit.addEventListener("click", () => {
757      performSearch(searchInput.value);
758    });
759  }
760
761  // Close search when clicking outside
762  document.addEventListener("click", (e) => {
763    if (
764      isSearchOpen &&
765      !searchForm.contains(e.target) &&
766      !searchToggle.contains(e.target)
767    ) {
768      toggleSearch();
769    }
770  });
771
772  // Keyboard shortcuts
773  document.addEventListener("keydown", (e) => {
774    // Theme toggle with 't' key
775    if (e.key === "t" && !e.ctrlKey && !e.metaKey && !e.altKey) {
776      const activeElement = document.activeElement;
777      if (
778        activeElement.tagName !== "INPUT" &&
779        activeElement.tagName !== "TEXTAREA"
780      ) {
781        if (themeToggle) {
782          themeToggle.click();
783        }
784      }
785    }
786
787    // Mute toggle with 'm' key
788    if (e.key === "m" && !e.ctrlKey && !e.metaKey && !e.altKey) {
789      const activeElement = document.activeElement;
790      if (
791        activeElement.tagName !== "INPUT" &&
792        activeElement.tagName !== "TEXTAREA"
793      ) {
794        if (muteToggle) {
795          muteToggle.click();
796        }
797      }
798    }
799  });
800
801  // SPA (Single Page Application) System
802  class LofiCodeSPA {
803    constructor() {
804      this.postsData = [];
805      this.pagesData = [];
806      this.currentView = "home";
807      this.currentPost = null;
808      this.currentPage = null;
809      this.isLoading = false;
810      this.isRealPostPage = false;
811      this.isRealPage = false;
812
813      this.init();
814    }
815
816    async init() {
817      // Check if we're on a real post page (not homepage with SPA)
818      this.detectPageType();
819
820      // Load posts and pages data
821      await this.loadPostsData();
822      await this.loadPagesData();
823
824      // Set up event listeners
825      this.setupEventListeners();
826
827      // Handle initial URL
828      this.handleInitialRoute();
829
830      // Initialize comments on direct post pages.
831      initRemark42Comments(document);
832    }
833
834    detectPageType() {
835      // Check if we're on a real post page by looking for post content structure
836      const postContent = document.querySelector('.post-layout .post-content .post-body');
837      const isPostURL = window.location.pathname.startsWith('/blog/');
838
839      this.isRealPostPage = isPostURL && postContent;
840
841      if (this.isRealPostPage) {
842        console.log('📄 Detected real post page, SPA overlay disabled');
843        this.currentView = "post";
844      }
845    }
846
847    async loadPostsData() {
848      try {
849        const response = await fetch("/index.json");
850        if (response.ok) {
851          this.postsData = await response.json();
852          console.log(
853            "📚 Loaded",
854            this.postsData.length,
855            "posts for SPA navigation"
856          );
857        } else {
858          console.log("Could not load posts data for SPA");
859        }
860      } catch (error) {
861        console.log("Error loading posts data:", error);
862      }
863    }
864
865    async loadPagesData() {
866      try {
867        const response = await fetch("/talks/pages.json");
868        if (response.ok) {
869          this.pagesData = await response.json();
870          console.log(
871            "📄 Loaded",
872            this.pagesData.length,
873            "pages for SPA navigation"
874          );
875        } else {
876          console.log("Could not load pages data for SPA");
877        }
878      } catch (error) {
879        console.log("Error loading pages data:", error);
880      }
881    }
882
883    setupEventListeners() {
884      // Only set up SPA navigation if we're not on a real post page
885      if (!this.isRealPostPage) {
886        // Intercept all internal links
887        document.addEventListener("click", (e) => {
888          const link = e.target.closest("a");
889          if (!link) return;
890
891          const href = link.getAttribute("href");
892
893          // Skip external links, anchors, and special keys
894          if (!href || href.startsWith("http") || href.startsWith("#") || e.ctrlKey || e.metaKey) {
895            return;
896          }
897
898          console.log("🔗 Link clicked:", href, "Link element:", link);
899
900          // Check if it's a post link
901          if (href.startsWith("/blog/")) {
902            console.log("📝 Intercepting post link:", href);
903            e.preventDefault();
904            this.navigateToPost(href);
905            return;
906          }
907
908          // Check if it's a page link - be more specific about which pages to intercept
909          // Only intercept links that match our known pages
910          const pageUrls = this.pagesData.map(p => p.url);
911          const normalizedHref = href.endsWith('/') ? href : href + '/';
912
913          if (pageUrls.includes(normalizedHref) || pageUrls.includes(href)) {
914            console.log("📄 Intercepting page link:", href);
915            e.preventDefault();
916            this.navigateToPage(href);
917            return;
918          }
919
920          console.log("🔗 Allowing normal navigation for:", href);
921        });
922
923        // Handle browser back/forward
924        window.addEventListener("popstate", (e) => {
925          if (e.state) {
926            if (e.state.type === "post") {
927              this.showPost(e.state.slug, false);
928            } else if (e.state.type === "page") {
929              this.showPage(e.state.slug, false);
930            } else if (e.state.type === "home") {
931              this.showHome(false);
932            }
933          } else {
934            this.showHome(false);
935          }
936        });
937
938        // Close overlays with Escape key
939        document.addEventListener("keydown", (e) => {
940          if (e.key === "Escape" && (this.currentView === "post" || this.currentView === "page")) {
941            this.showHome();
942          }
943        });
944      } else {
945        // On real post pages, add a "back to home" button
946        this.addBackToHomeButton();
947      }
948    }
949
950    addBackToHomeButton() {
951      // Check if there's already a back button
952      if (document.querySelector('.back-to-home-btn')) return;
953
954      // Create back to home button
955      const backButton = document.createElement('button');
956      backButton.className = 'back-to-home-btn';
957      backButton.innerHTML = '<i class="fas fa-times"></i>';
958      backButton.title = 'Back to Home';
959      backButton.style.cssText = `
960        position: fixed;
961        top: 2rem;
962        right: 2rem;
963        z-index: 1000;
964        background: var(--bg-secondary);
965        border: 2px solid var(--accent-primary);
966        border-radius: 50%;
967        width: 3rem;
968        height: 3rem;
969        display: flex;
970        align-items: center;
971        justify-content: center;
972        cursor: pointer;
973        color: var(--text-primary);
974        font-size: 1.2rem;
975        transition: all 0.3s ease;
976        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
977      `;
978
979      // Add hover effects
980      backButton.addEventListener('mouseenter', () => {
981        backButton.style.transform = 'scale(1.1)';
982        backButton.style.background = 'var(--accent-primary)';
983        backButton.style.color = 'var(--bg-primary)';
984      });
985
986      backButton.addEventListener('mouseleave', () => {
987        backButton.style.transform = 'scale(1)';
988        backButton.style.background = 'var(--bg-secondary)';
989        backButton.style.color = 'var(--text-primary)';
990      });
991
992      // Navigate to home when clicked
993      backButton.addEventListener('click', () => {
994        window.location.href = '/';
995      });
996
997      document.body.appendChild(backButton);
998    }
999
1000    handleInitialRoute() {
1001      // Only handle SPA routing if we're not on a real post page
1002      if (!this.isRealPostPage) {
1003        const path = window.location.pathname;
1004        if (path.startsWith("/blog/")) {
1005          const slug = path.split("/blog/")[1].replace("/", "");
1006          if (slug) {
1007            this.showPost(slug, false);
1008          }
1009        }
1010      }
1011    }
1012
1013    navigateToPost(href) {
1014      const slug = href.split("/blog/")[1].replace("/", "");
1015      this.showPost(slug, true);
1016    }
1017
1018    navigateToPage(href) {
1019      // Extract page slug from URL (e.g., "/about/" -> "about")
1020      const slug = href.replace(/^\/|\/$/g, ''); // Remove leading and trailing slashes
1021      this.showPage(slug, true);
1022    }
1023
1024    async showPage(slug, updateHistory = true) {
1025      if (this.isLoading) return;
1026
1027      this.isLoading = true;
1028      this.currentView = "page";
1029
1030      // Find page data
1031      let page = this.pagesData.find((p) => {
1032        const pageSlug = p.url.replace(/^\/|\/$/g, '');
1033        return pageSlug === slug || p.slug === slug;
1034      });
1035
1036      if (!page) {
1037        console.log("Page not found:", slug);
1038        console.log("Available pages:", this.pagesData.map(p => ({ slug: p.slug, url: p.url })));
1039        this.isLoading = false;
1040        return;
1041      }
1042
1043      this.currentPage = page;
1044
1045      // Update URL if needed
1046      if (updateHistory) {
1047        const newUrl = `/${slug}/`;
1048        history.pushState({ type: "page", slug }, page.title, newUrl);
1049      }
1050
1051      // Create and show page overlay
1052      this.createPageOverlay(page);
1053
1054      this.isLoading = false;
1055    }
1056
1057    createPageOverlay(page) {
1058      // Remove existing overlay
1059      const existingOverlay = document.querySelector(".post-overlay");
1060      if (existingOverlay) {
1061        existingOverlay.remove();
1062      }
1063
1064      // Create overlay
1065      const overlay = document.createElement("div");
1066      overlay.className = "post-overlay";
1067
1068      // Get site title from the main page logo
1069      const siteTitle = document.querySelector('.logo')?.textContent || 'LofiCode';
1070
1071      overlay.innerHTML = `
1072        <div class="post-overlay-backdrop"></div>
1073        <div class="post-overlay-content">
1074          <div class="post-overlay-header">
1075            <div class="post-overlay-title">
1076              <a href="/" class="overlay-site-title">${siteTitle}</a>
1077            </div>
1078            <button class="post-overlay-close" aria-label="Close page">
1079              <i class="fas fa-times"></i>
1080            </button>
1081          </div>
1082          <div class="post-layout">
1083            <article class="post-content">
1084              <header class="post-header">
1085                <h1 class="post-title">${page.title}</h1>
1086              </header>
1087              <div class="post-body">
1088                ${page.content}
1089              </div>
1090            </article>
1091          </div>
1092        </div>
1093      `;
1094
1095      document.body.appendChild(overlay);
1096
1097      // Add event listeners
1098      const closeBtn = overlay.querySelector(".post-overlay-close");
1099      const backdrop = overlay.querySelector(".post-overlay-backdrop");
1100      const siteTitleLink = overlay.querySelector(".overlay-site-title");
1101
1102      closeBtn.addEventListener("click", () => this.showHome());
1103      backdrop.addEventListener("click", () => this.showHome());
1104
1105      // Handle site title click in SPA overlay - should close overlay, not reload page
1106      if (siteTitleLink) {
1107        siteTitleLink.addEventListener("click", (e) => {
1108          e.preventDefault();
1109          this.showHome();
1110        });
1111      }
1112
1113      // Animate in
1114      requestAnimationFrame(() => {
1115        overlay.classList.add("active");
1116      });
1117
1118      // Re-initialize code highlighting and other features
1119      setTimeout(() => {
1120        initializeCodeFences();
1121        // Re-initialize ambient sound controls for the overlay
1122        this.initializeOverlayAmbientControls(overlay);
1123      }, 100);
1124    }
1125
1126    async showPost(slug, updateHistory = true) {
1127      if (this.isLoading) return;
1128
1129      this.isLoading = true;
1130      this.currentView = "post";
1131
1132      // Find post data - try multiple ways to match the slug
1133      let post = this.postsData.find((p) => p.slug === slug);
1134
1135      // If not found by slug, try matching by URL
1136      if (!post) {
1137        post = this.postsData.find((p) => {
1138          const urlSlug = p.url.split('/').filter(Boolean).pop();
1139          return urlSlug === slug;
1140        });
1141      }
1142
1143      // If still not found, try matching by filename
1144      if (!post) {
1145        post = this.postsData.find((p) => {
1146          const filename = p.url.split('/').filter(Boolean).pop();
1147          return filename === slug || filename === slug + '/';
1148        });
1149      }
1150
1151      if (!post) {
1152        console.log("Post not found:", slug);
1153        console.log("Available posts:", this.postsData.map(p => ({ slug: p.slug, url: p.url })));
1154        this.isLoading = false;
1155        return;
1156      }
1157
1158      this.currentPost = post;
1159
1160      // Update URL if needed
1161      if (updateHistory) {
1162        const newUrl = `/blog/${slug}/`;
1163        history.pushState({ type: "post", slug }, post.title, newUrl);
1164      }
1165
1166      // Create and show post overlay
1167      this.createPostOverlay(post);
1168
1169      this.isLoading = false;
1170    }
1171
1172    createPostOverlay(post) {
1173      // Remove existing overlay
1174      const existingOverlay = document.querySelector(".post-overlay");
1175      if (existingOverlay) {
1176        destroyRemark42Comments();
1177        existingOverlay.remove();
1178      }
1179
1180      // Find related posts and prev/next navigation
1181      const relatedPosts = this.findRelatedPosts(post);
1182      const navigation = this.findPrevNextPosts(post);
1183
1184      // Create overlay
1185      const overlay = document.createElement("div");
1186      overlay.className = "post-overlay";
1187
1188      // Get site title from the main page logo
1189      const siteTitle = document.querySelector('.logo')?.textContent || 'LofiCode';
1190
1191      overlay.innerHTML = `
1192        <div class="post-overlay-backdrop"></div>
1193        <div class="post-overlay-content">
1194          <div class="post-overlay-header">
1195            <div class="post-overlay-title">
1196              <a href="/" class="overlay-site-title">${siteTitle}</a>
1197            </div>
1198            <button class="post-overlay-close" aria-label="Close post">
1199              <i class="fas fa-times"></i>
1200            </button>
1201          </div>
1202          <div class="post-layout">
1203            <article class="post-content">
1204              <header class="post-header">
1205                <div class="post-meta">
1206                  <span class="post-date">${post.dateFormatted}</span>
1207                  <div class="reading-time-post">
1208                    <span class="coffee-cups">${"📚".repeat(
1209        Math.max(1, Math.min(5, Math.ceil(post.readingTime / 3)))
1210      )}</span>
1211                    <span>${post.readingTime} min read</span>
1212                  </div>
1213                </div>
1214                <h1 class="post-title">${post.title}</h1>
1215                ${post.subtitle
1216          ? `<p class="post-subtitle">${post.subtitle}</p>`
1217          : ""
1218        }
1219                ${post.tags && post.tags.length > 0
1220          ? `
1221                  <div class="post-tags-header">
1222                    ${post.tags
1223            .map((tag) => `<a href="/tags/${tag.toLowerCase().replace(/\s+/g, '-')}" class="post-tag">${tag}</a>`)
1224            .join("")}
1225                  </div>
1226                `
1227          : ""
1228        }
1229              </header>
1230              <div class="post-body">
1231                ${post.content}
1232              </div>
1233
1234              ${this.renderRemark42Comments(post)}
1235
1236              ${relatedPosts.length > 0
1237          ? `
1238                <div class="related-posts">
1239                  <h3>Related Content</h3>
1240                  <div class="related-grid">
1241                    ${relatedPosts
1242            .map(
1243              (relatedPost) => `
1244                      <div class="related-post">
1245                        <h4><a href="${relatedPost.url}" data-spa-link>${relatedPost.title
1246                }</a></h4>
1247                        <div class="related-meta">
1248                          ${relatedPost.dateFormatted} •
1249                          ${"📚".repeat(
1250                  Math.max(1, Math.ceil(relatedPost.readingTime / 3))
1251                )} ${relatedPost.readingTime} min read
1252                        </div>
1253                      </div>
1254                    `
1255            )
1256            .join("")}
1257                  </div>
1258                </div>
1259              `
1260          : ""
1261        }
1262
1263              ${navigation.prev || navigation.next
1264          ? `
1265                <nav class="post-navigation">
1266                  ${navigation.prev
1267            ? `
1268                    <div class="nav-previous">
1269                      <span class="nav-label">← Previous</span>
1270                      <a href="${navigation.prev.url}" data-spa-link class="nav-title">${navigation.prev.title}</a>
1271                    </div>
1272                  `
1273            : "<div></div>"
1274          }
1275                  ${navigation.next
1276            ? `
1277                    <div class="nav-next">
1278                      <span class="nav-label">Next →</span>
1279                      <a href="${navigation.next.url}" data-spa-link class="nav-title">${navigation.next.title}</a>
1280                    </div>
1281                  `
1282            : "<div></div>"
1283          }
1284                </nav>
1285              `
1286          : ""
1287        }
1288            </article>
1289
1290            <aside class="sidebar">
1291              <nav class="toc">
1292                <h4>Contents</h4>
1293                <div id="toc-content">
1294                  <!-- TOC will be generated by JavaScript -->
1295                </div>
1296              </nav>
1297            </aside>
1298          </div>
1299
1300        </div>
1301      `;
1302
1303      document.body.appendChild(overlay);
1304
1305      // Add event listeners
1306      const closeBtn = overlay.querySelector(".post-overlay-close");
1307      const backdrop = overlay.querySelector(".post-overlay-backdrop");
1308      const siteTitleLink = overlay.querySelector(".overlay-site-title");
1309
1310      closeBtn.addEventListener("click", () => this.showHome());
1311      backdrop.addEventListener("click", () => this.showHome());
1312
1313      // Handle site title click in SPA overlay - should close overlay, not reload page
1314      if (siteTitleLink) {
1315        siteTitleLink.addEventListener("click", (e) => {
1316          e.preventDefault();
1317          this.showHome();
1318        });
1319      }
1320
1321      // Add SPA navigation for related posts and prev/next links
1322      const spaLinks = overlay.querySelectorAll("a[data-spa-link]");
1323      spaLinks.forEach((link) => {
1324        link.addEventListener("click", (e) => {
1325          e.preventDefault();
1326          const href = link.getAttribute("href");
1327          if (href && href.startsWith("/blog/")) {
1328            const slug = href.split("/blog/")[1].replace("/", "");
1329            this.showPost(slug, true);
1330          }
1331        });
1332      });
1333
1334      // Animate in
1335      requestAnimationFrame(() => {
1336        overlay.classList.add("active");
1337      });
1338
1339      // Re-initialize code highlighting and other post features
1340      setTimeout(() => {
1341        initializeCodeFences();
1342        this.generateOverlayTOC(overlay);
1343        initRemark42Comments(overlay);
1344
1345        // Re-initialize ambient sound controls for the overlay
1346        this.initializeOverlayAmbientControls(overlay);
1347      }, 100);
1348    }
1349
1350    renderRemark42Comments(post) {
1351      if (!post.comments || !window.remark42SiteConfig) {
1352        return "";
1353      }
1354
1355      return `
1356        <section
1357          id="comments"
1358          class="remark-comments"
1359          aria-labelledby="remark-comments-title"
1360          data-remark42-comments
1361          data-remark42-url="${escapeHtml(remark42ThreadURL(post))}"
1362          data-remark42-title="${escapeHtml(post.title)}"
1363        >
1364          <div class="remark-comments-header">
1365            <div>
1366              <p class="remark-comments-kicker">Discussion</p>
1367              <h2 id="remark-comments-title">Comments</h2>
1368            </div>
1369          </div>
1370
1371          <div id="remark42" class="remark42-container">
1372            <div class="remark-comments-loading">Loading comments...</div>
1373          </div>
1374        </section>
1375      `;
1376    }
1377
1378    findRelatedPosts(currentPost) {
1379      if (!currentPost.tags || currentPost.tags.length === 0) {
1380        return [];
1381      }
1382
1383      // Find posts with matching tags
1384      const related = this.postsData
1385        .filter((post) => post.slug !== currentPost.slug) // Exclude current post
1386        .map((post) => {
1387          // Calculate relevance score based on shared tags
1388          const sharedTags = post.tags
1389            ? post.tags.filter((tag) => currentPost.tags.includes(tag)).length
1390            : 0;
1391
1392          return { ...post, relevanceScore: sharedTags };
1393        })
1394        .filter((post) => post.relevanceScore > 0) // Only posts with shared tags
1395        .sort((a, b) => b.relevanceScore - a.relevanceScore) // Sort by relevance
1396        .slice(0, 3); // Take top 3
1397
1398      return related;
1399    }
1400
1401    findPrevNextPosts(currentPost) {
1402      // Sort posts by date (newest first)
1403      const sortedPosts = [...this.postsData].sort(
1404        (a, b) => new Date(b.date) - new Date(a.date)
1405      );
1406
1407      const currentIndex = sortedPosts.findIndex(
1408        (post) => post.slug === currentPost.slug
1409      );
1410
1411      return {
1412        prev: currentIndex > 0 ? sortedPosts[currentIndex - 1] : null,
1413        next:
1414          currentIndex < sortedPosts.length - 1
1415            ? sortedPosts[currentIndex + 1]
1416            : null,
1417      };
1418    }
1419
1420    generateOverlayTOC(overlay) {
1421      const tocContent = overlay.querySelector("#toc-content");
1422      const headings = overlay.querySelectorAll(".post-body h2, .post-body h3");
1423
1424      if (!tocContent || headings.length === 0) return;
1425
1426      const tocList = document.createElement("ul");
1427
1428      headings.forEach((heading, index) => {
1429        // Add ID to heading if it doesn't have one
1430        if (!heading.id) {
1431          heading.id = `heading-${index}`;
1432        }
1433
1434        const li = document.createElement("li");
1435        const a = document.createElement("a");
1436        a.href = `#${heading.id}`;
1437        a.textContent = heading.textContent;
1438        a.addEventListener("click", (e) => {
1439          e.preventDefault();
1440          // For overlay, we need to scroll within the overlay content
1441          const overlayContent = overlay.querySelector('.post-overlay-content');
1442          const overlayHeaderHeight = overlay.querySelector('.post-overlay-header')?.offsetHeight || 60;
1443
1444          if (overlayContent) {
1445            const targetPosition = heading.getBoundingClientRect().top + overlayContent.scrollTop - overlayHeaderHeight - 20;
1446            overlayContent.scrollTo({
1447              top: targetPosition,
1448              behavior: "smooth"
1449            });
1450          }
1451        });
1452
1453        li.appendChild(a);
1454        tocList.appendChild(li);
1455      });
1456
1457      tocContent.appendChild(tocList);
1458
1459      // Set up scroll listener for overlay TOC highlighting
1460      const overlayContent = overlay.querySelector('.post-overlay-content');
1461      if (overlayContent) {
1462        overlayContent.addEventListener('scroll', () => {
1463          this.updateOverlayTOC(overlay);
1464        });
1465        this.updateOverlayTOC(overlay); // Initial call
1466      }
1467    }
1468
1469    updateOverlayTOC(overlay) {
1470      const headings = overlay.querySelectorAll(".post-body h2, .post-body h3");
1471      const tocLinks = overlay.querySelectorAll("#toc-content a");
1472
1473      if (headings.length === 0 || tocLinks.length === 0) return;
1474
1475      const overlayContent = overlay.querySelector('.post-overlay-content');
1476      if (!overlayContent) return;
1477
1478      let current = 0;
1479      headings.forEach((heading, i) => {
1480        const rect = heading.getBoundingClientRect();
1481        const overlayRect = overlayContent.getBoundingClientRect();
1482        const relativeTop = rect.top - overlayRect.top;
1483
1484        if (relativeTop <= 100) {
1485          current = i;
1486        }
1487      });
1488
1489      tocLinks.forEach((link, i) => {
1490        link.classList.toggle("active", i === current);
1491      });
1492    }
1493
1494    showHome(updateHistory = true) {
1495      this.currentView = "home";
1496      this.currentPost = null;
1497
1498      // Remove post overlay
1499      const overlay = document.querySelector(".post-overlay");
1500      if (overlay) {
1501        destroyRemark42Comments();
1502        overlay.classList.remove("active");
1503        setTimeout(() => {
1504          overlay.remove();
1505        }, 300);
1506      }
1507
1508      // Update URL if needed
1509      if (updateHistory) {
1510        history.pushState({ type: "home" }, "LofiCode", "/");
1511      }
1512    }
1513  }
1514
1515  // Initialize SPA system
1516  let spa = null;
1517
1518  // Wait for DOM to be ready
1519  if (document.readyState === "loading") {
1520    document.addEventListener("DOMContentLoaded", () => {
1521      spa = new LofiCodeSPA();
1522      window.spa = spa; // Make globally accessible
1523    });
1524  } else {
1525    spa = new LofiCodeSPA();
1526    window.spa = spa; // Make globally accessible
1527  }
1528
1529  // Load More Posts functionality
1530  const loadMoreBtn = document.getElementById('load-more-btn');
1531  const loadMoreLoading = document.getElementById('load-more-loading');
1532  const postsContainer = document.getElementById('posts-container');
1533
1534  if (loadMoreBtn && postsContainer) {
1535    loadMoreBtn.addEventListener('click', async () => {
1536      const loaded = parseInt(loadMoreBtn.dataset.loaded);
1537      const total = parseInt(loadMoreBtn.dataset.total);
1538      const postsPerPage = 3;
1539
1540      // Show loading state
1541      loadMoreBtn.style.display = 'none';
1542      loadMoreLoading.style.display = 'flex';
1543
1544      try {
1545        // Fetch posts data from index.json
1546        const response = await fetch('/index.json');
1547        const postsData = await response.json();
1548
1549        // Get the next batch of posts
1550        const nextPosts = postsData.slice(loaded, loaded + postsPerPage);
1551
1552        // Create post elements
1553        for (const post of nextPosts) {
1554          const postElement = await createPostElement(post);
1555          postsContainer.appendChild(postElement);
1556
1557          // Add a small delay for smooth animation
1558          await new Promise(resolve => setTimeout(resolve, 100));
1559        }
1560
1561        // Update loaded count
1562        const newLoaded = loaded + nextPosts.length;
1563        loadMoreBtn.dataset.loaded = newLoaded;
1564
1565        // Hide loading state
1566        loadMoreLoading.style.display = 'none';
1567
1568        // Show button again if there are more posts
1569        if (newLoaded < total) {
1570          loadMoreBtn.style.display = 'flex';
1571        }
1572
1573      } catch (error) {
1574        console.error('Error loading more posts:', error);
1575        loadMoreLoading.style.display = 'none';
1576        loadMoreBtn.style.display = 'flex';
1577        loadMoreBtn.innerHTML = '<span class="load-more-text">Error loading posts</span><span class="load-more-icon">😞</span>';
1578      }
1579    });
1580  }
1581
1582  // Create post element from post data
1583  async function createPostElement(post) {
1584    const postDiv = document.createElement('article');
1585    postDiv.className = `post-item${post.featured ? ' featured' : ''}`;
1586
1587    // Calculate coffee cups for reading time (max 5 cups)
1588    const cupsToShow = Math.min(post.readingTime, 5);
1589    const coffeeCups = "📚".repeat(cupsToShow);
1590
1591    // Create mood badge if mood exists
1592    const moodBadge = post.mood ? `<span class="post-mood">${post.mood}</span>` : '';
1593
1594    // Create featured badge if featured
1595    const featuredBadge = post.featured ? '<span class="featured-badge">✨ Featured</span>' : '';
1596
1597    // Create tags
1598    const tagsHtml = post.tags && post.tags.length > 0
1599      ? post.tags.map(tag => `<span class="post-tag-vaporwave">${tag}</span>`).join('')
1600      : '';
1601
1602    postDiv.innerHTML = `
1603      <div class="post-content">
1604        <div class="post-header-inline">
1605          <h2 class="post-title-vaporwave">
1606            <a href="${post.url}">${post.title}</a>
1607          </h2>
1608        </div>
1609
1610        <div class="post-date-with-badges">
1611          <span class="post-date">${post.dateFormatted}</span>
1612          ${featuredBadge}
1613          ${moodBadge}
1614        </div>
1615
1616        ${post.subtitle ? `<p class="post-list-subtitle">${post.subtitle}</p>` : ''}
1617
1618        <p class="post-excerpt-vaporwave">
1619          ${post.excerpt}
1620        </p>
1621
1622        ${tagsHtml ? `<div class="post-tags-vaporwave">${tagsHtml}</div>` : ''}
1623
1624        <a href="${post.url}" class="continue-reading-vaporwave">
1625          Read More →
1626        </a>
1627      </div>
1628
1629      <div class="post-meta-sidebar">
1630        <div class="reading-time-vaporwave">
1631          <span class="coffee-cups">
1632            ${coffeeCups}
1633            <span>${post.readingTime} min</span>
1634          </span>
1635        </div>
1636      </div>
1637    `;
1638
1639    // Add fade-in animation
1640    postDiv.style.opacity = '0';
1641    postDiv.style.transform = 'translateY(20px)';
1642
1643    // Trigger animation after a brief delay
1644    setTimeout(() => {
1645      postDiv.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
1646      postDiv.style.opacity = '1';
1647      postDiv.style.transform = 'translateY(0)';
1648    }, 50);
1649
1650    return postDiv;
1651  }
1652
1653  // Console easter egg
1654  console.log(`
1655    🤖 Welcome to @gvolpe's blog! 🤖
1656
1657    You found the console! Any issues can be reported here:
1658
1659    * https://github.com/gvolpe/gvolpe.github.io/issues
1660
1661    Happy blog-surgfing! 🏄✨
1662    `);
1663})();

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.