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 "&": "&", 516 "<": "<", 517 ">": ">", 518 '"': """, 519 "'": "'", 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.