1document.addEventListener("DOMContentLoaded", function () { 2 // ============================================ 3 // Theme Toggle (light â dark â system) 4 // ============================================ 5 // The saved theme lives in localStorage.theme ("light", "dark", or absent 6 // for "system"); an inline script in base.html applies it before first 7 // paint. This section owns everything after that: the toggle buttons, 8 // reacting to OS theme changes while in system mode, and re-theming 9 // EthicalAds placements. 10 const systemDarkQuery = window.matchMedia("(prefers-color-scheme: dark)"); 11 12 const getTheme = () => 13 localStorage.theme === "light" || localStorage.theme === "dark" ? localStorage.theme : "system"; 14 15 const applyTheme = (theme) => { 16 const isDark = theme === "dark" || (theme === "system" && systemDarkQuery.matches); 17 18 // Flip atomically: without this, every element with transition-* 19 // fades to its new colors over ~200ms in a visible ripple. Disable 20 // transitions, apply the theme, force a style recalc, then re-enable. 21 const freeze = document.createElement("style"); 22 freeze.textContent = "*, *::before, *::after { transition: none !important; }"; 23 document.head.appendChild(freeze); 24 25 document.documentElement.classList.toggle("dark", isDark); 26 27 // EthicalAds styles its placements from a `dark` class on the 28 // [data-ea-publisher] element itself, not from an ancestor class. 29 document.querySelectorAll("[data-ea-publisher]").forEach((ad) => { 30 ad.classList.toggle("dark", isDark); 31 }); 32 33 // The navbar floats over the page, so mobile browser chrome should 34 // match the page background (hex values of --color-background). 35 const themeColorMeta = document.querySelector('meta[name="theme-color"]'); 36 if (themeColorMeta) { 37 themeColorMeta.content = isDark ? "#070b09" : "#f8f8f8"; 38 } 39 40 const icons = { light: "ph-sun", dark: "ph-moon", system: "ph-circle-half" }; 41 document.querySelectorAll("[data-theme-toggle]").forEach((button) => { 42 button.title = `Theme: ${theme}`; 43 button.setAttribute("aria-label", `Change theme (current: ${theme})`); 44 button.querySelector("i").className = `ph-bold ${icons[theme]} text-xl`; 45 }); 46 47 window.getComputedStyle(document.documentElement).opacity; 48 window.setTimeout(() => freeze.remove(), 0); 49 }; 50 51 document.querySelectorAll("[data-theme-toggle]").forEach((button) => { 52 button.addEventListener("click", () => { 53 const order = ["light", "dark", "system"]; 54 const next = order[(order.indexOf(getTheme()) + 1) % order.length]; 55 if (next === "system") { 56 localStorage.removeItem("theme"); 57 } else { 58 localStorage.theme = next; 59 } 60 applyTheme(next); 61 }); 62 }); 63 64 systemDarkQuery.addEventListener("change", () => { 65 if (getTheme() === "system") { 66 applyTheme("system"); 67 } 68 }); 69 70 // Keep other open tabs in sync when the theme is changed in one of them. 71 window.addEventListener("storage", (event) => { 72 if (event.key === "theme" || event.key === null) { 73 applyTheme(getTheme()); 74 } 75 }); 76 77 // Print with the light theme: browsers skip background colors when 78 // printing, so dark-mode text would come out light-on-white. 79 window.addEventListener("beforeprint", () => { 80 document.documentElement.classList.remove("dark"); 81 }); 82 window.addEventListener("afterprint", () => { 83 applyTheme(getTheme()); 84 }); 85 86 applyTheme(getTheme()); 87 88 // ============================================ 89 // Grid Comparison Table Functions 90 // ============================================ 91 92 function abbreviateGridNumbers() { 93 document.querySelectorAll('[data-abbreviate]').forEach(function(el) { 94 if (el.dataset.abbreviated) return; 95 const num = parseInt(el.textContent.replace(/,/g, ''), 10); 96 if (!isNaN(num) && num >= 1000) {
97 el.title = num.toLocaleString(); 98 if (num >= 1000000) { 99 el.textContent = (num / 1000000).toFixed(1) + 'M'; 100 } else if (num >= 1000) { 101 el.textContent = (num / 1000).toFixed(1) + 'K'; 102 } 103 el.dataset.abbreviated = 'true'; 104 } 105 }); 106 } 107 108 // Initialize grid table if present 109 if (document.getElementById('comparison-table')) { 110 abbreviateGridNumbers(); 111 } 112 113 // Re-initialize after HTMX swaps 114 document.body.addEventListener('htmx:afterSwap', function(evt) { 115 if (evt.detail.target.id === 'comparison-table') { 116 abbreviateGridNumbers(); 117 initMobilePackageNav(); 118 } 119 }); 120 121 // Mobile package navigation for comparison grid 122 function initMobilePackageNav() { 123 const cardsContainer = document.querySelector('.mobile-pkg-cards'); 124 const swipeArea = document.querySelector('.mobile-pkg-swipe-area'); 125 126 if (!cardsContainer || !swipeArea) return; 127 128 // Get fresh references to buttons and dots 129 function getButtons() { 130 return document.querySelectorAll('.mobile-pkg-btn'); 131 } 132 function getDots() { 133 return document.querySelectorAll('.mobile-pkg-dot'); 134 } 135 136 // Store current index on the container element so it persists 137 if (!swipeArea.dataset.currentIndex) { 138 swipeArea.dataset.currentIndex = '0'; 139 } 140 141 function getCurrentIndex() { 142 return parseInt(swipeArea.dataset.currentIndex, 10) || 0; 143 } 144 145 function setCurrentIndex(index) { 146 swipeArea.dataset.currentIndex = String(index); 147 } 148 149 function switchToPackage(index) { 150 const buttons = getButtons(); 151 const dots = getDots(); 152 153 // Clamp index to valid range 154 index = Math.max(0, Math.min(index, buttons.length - 1)); 155 setCurrentIndex(index); 156 157 // Update cards position 158 cardsContainer.style.transform = `translateX(-${index * 100}%)`; 159 160 // Update buttons 161 buttons.forEach((btn, i) => { 162 if (i === index) { 163 btn.classList.add('bg-primary', 'text-primary-foreground', 'border-primary'); 164 btn.classList.remove('bg-card', 'text-foreground', 'border-border', 'hover:border-primary'); 165 } else { 166 btn.classList.remove('bg-primary', 'text-primary-foreground', 'border-primary'); 167 btn.classList.add('bg-card', 'text-foreground', 'border-border', 'hover:border-primary'); 168 } 169 }); 170 171 // Update dots 172 dots.forEach((dot, i) => { 173 if (i === index) { 174 dot.classList.add('bg-primary', 'w-4'); 175 dot.classList.remove('bg-muted-foreground/30'); 176 } else { 177 dot.classList.remove('bg-primary', 'w-4'); 178 dot.classList.add('bg-muted-foreground/30'); 179 } 180 }); 181 182 // Scroll the active button into view 183 buttons[index]?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); 184 } 185 186 // Use event delegation for buttons and dots 187 document.addEventListener('click', (e) => { 188 const btn = e.target.closest('.mobile-pkg-btn'); 189 const dot = e.target.closest('.mobile-pkg-dot'); 190 191 if (btn) { 192 e.preventDefault(); 193 const index = parseInt(btn.dataset.pkgIndex, 10); 194 switchToPackage(index); 195 } else if (dot) { 196 e.preventDefault(); 197 const index = parseInt(dot.dataset.pkgIndex, 10); 198 switchToPackage(index); 199 } 200 }); 201 202 // Swipe support - attach to the swipe area wrapper 203 let touchStartX = 0; 204 let touchStartY = 0; 205 let isDragging = false; 206 let swipeDirection = null; // 'horizontal', 'vertical', or null 207 208 function handleTouchStart(e) { 209 touchStartX = e.touches[0].clientX; 210 touchStartY = e.touches[0].clientY; 211 isDragging = true; 212 swipeDirection = null; 213 } 214 215 function handleTouchMove(e) { 216 if (!isDragging) return; 217 218 const touchCurrentX = e.touches[0].clientX; 219 const touchCurrentY = e.touches[0].clientY; 220 const diffX = touchCurrentX - touchStartX; 221 const diffY = touchCurrentY - touchStartY; 222 223 // Determine swipe direction once we've moved enough 224 if (swipeDirection === null && (Math.abs(diffX) > 10 || Math.abs(diffY) > 10)) { 225 swipeDirection = Math.abs(diffX) > Math.abs(diffY) ? 'horizontal' : 'vertical'; 226 } 227 228 // If horizontal swipe, prevent default to stop page scroll 229 if (swipeDirection === 'horizontal') { 230 e.preventDefault(); 231 } 232 } 233 234 function handleTouchEnd(e) { 235 if (!isDragging) return; 236 237 const buttons = getButtons(); 238 const touchEndX = e.changedTouches[0].clientX; 239 const diff = touchStartX - touchEndX; 240 const threshold = 50; 241 const currentIndex = getCurrentIndex(); 242 243 if (swipeDirection === 'horizontal' && Math.abs(diff) > threshold) { 244 if (diff > 0 && currentIndex < buttons.length - 1) {
245 // Swipe left - go to next 246 switchToPackage(currentIndex + 1); 247 } else if (diff < 0 && currentIndex > 0) { 248 // Swipe right - go to previous 249 switchToPackage(currentIndex - 1); 250 } 251 } 252 253 isDragging = false; 254 swipeDirection = null; 255 } 256 257 // Only add listeners once - check for flag 258 if (!swipeArea.dataset.swipeInit) { 259 swipeArea.dataset.swipeInit = 'true'; 260 swipeArea.addEventListener('touchstart', handleTouchStart, { passive: true }); 261 swipeArea.addEventListener('touchmove', handleTouchMove, { passive: false }); 262 swipeArea.addEventListener('touchend', handleTouchEnd, { passive: true }); 263 } 264 } 265 266 // Initialize mobile nav if present 267 initMobilePackageNav(); 268 269 // ============================================ 270 // Mobile Menu Toggle - Using data attributes 271 // ============================================ 272 const mobileMenuBtn = document.getElementById("mobile-menu-btn"); 273 const mobileMenu = document.getElementById("mobile-menu"); 274 275 if (mobileMenuBtn && mobileMenu) { 276 const toggle = () => { 277 const isOpen = mobileMenu.classList.contains("hidden"); 278 mobileMenu.classList.toggle("hidden"); 279 mobileMenuBtn.setAttribute("aria-expanded", isOpen); 280 mobileMenuBtn.querySelector("i").className = isOpen ? "ph-bold ph-x text-xl" : "ph-bold ph-list text-xl"; 281 }; 282 283 mobileMenuBtn.addEventListener("click", (e) => { 284 e.stopPropagation(); 285 toggle(); 286 }); 287 288 document.addEventListener("click", (e) => { 289 if ( 290 !mobileMenu.classList.contains("hidden") && 291 !mobileMenu.contains(e.target) && 292 !mobileMenuBtn.contains(e.target) 293 ) { 294 toggle(); 295 } 296 }); 297 298 document.addEventListener("keydown", (e) => { 299 if (e.key === "Escape" && !mobileMenu.classList.contains("hidden")) { 300 toggle(); 301 } 302 }); 303 } 304 305 // ============================================ 306 // Category Tags Horizontal Scrolling 307 // ============================================ 308 const scrollLeftBtn = document.getElementById("scroll-left"); 309 const scrollRightBtn = document.getElementById("scroll-right"); 310 const tagScroll1 = document.getElementById("tag-scroll-1"); 311 const tagScroll2 = document.getElementById("tag-scroll-2"); 312 313 if (scrollLeftBtn && scrollRightBtn && tagScroll1 && tagScroll2) { 314 scrollLeftBtn.addEventListener("click", () => { 315 const scrollAmount = 200; 316 tagScroll1.scrollBy({ left: -scrollAmount, behavior: "smooth" }); 317 tagScroll2.scrollBy({ left: -scrollAmount, behavior: "smooth" }); 318 }); 319 320 scrollRightBtn.addEventListener("click", () => { 321 const scrollAmount = 200; 322 tagScroll1.scrollBy({ left: scrollAmount, behavior: "smooth" }); 323 tagScroll2.scrollBy({ left: scrollAmount, behavior: "smooth" }); 324 }); 325 } 326 327 // ============================================ 328 // Horizontal Scrolling - Using event delegation 329 // ============================================ 330 document.addEventListener("click", (e) => { 331 const btn = e.target.closest("[data-scroll]"); 332 if (!btn) return; 333 334 const target = btn.dataset.scroll; 335 const elements = document.querySelectorAll(target); 336 const amount = btn.dataset.amount || 200; 337 338 elements.forEach((el) => 339 el.scrollBy({ 340 left: parseInt(amount), 341 behavior: "smooth", 342 }) 343 ); 344 }); 345 346 // ============================================ 347 // Search Suggestions 348 // ============================================ 349 350 // Click handler 351 document.addEventListener("click", (e) => { 352 const item = e.target.closest("a.suggestion-item"); 353 if (item?.href) { 354 window.location.href = item.href; 355 } 356 }); 357 358 // Setup keyboard navigation and dropdown visibility for each search input 359 const setupSuggestionNav = (input, dropdown) => { 360 if (!input || !dropdown) return; 361 362 const open = () => { dropdown.dataset.open = "true"; }; 363 const close = () => { dropdown.dataset.open = "false"; }; 364 365 // Keyboard navigation 366 input.addEventListener("keydown", (e) => { 367 const items = [...dropdown.querySelectorAll(".suggestion-item")]; 368 if (!items.length) return; 369 370 const current = items.findIndex(el => el.classList.contains("selected")); 371 372 if (e.key === "ArrowDown") { 373 e.preventDefault(); 374 if (current < items.length - 1) { 375 const next = current + 1;
376 items.forEach((el, i) => el.classList.toggle("selected", i === next)); 377 items[next]?.scrollIntoView({ block: "nearest" }); 378 } else { 379 // At the end - scroll load-more into view to trigger pagination 380 const loadMore = dropdown.querySelector(".suggestions-load-more"); 381 if (loadMore) { 382 loadMore.scrollIntoView({ block: "nearest" }); 383 } 384 else { 385 // Wrap to first item 386 items.forEach((el, i) => el.classList.toggle("selected", i === 0)); 387 items[0]?.scrollIntoView({ block: "nearest" }); 388 } 389 } 390 } else if (e.key === "ArrowUp") { 391 e.preventDefault(); 392 const prev = current > 0 ? current - 1 : items.length - 1; 393 items.forEach((el, i) => el.classList.toggle("selected", i === prev)); 394 items[prev]?.scrollIntoView({ block: "nearest" }); 395 } else if (e.key === "Enter" && current >= 0 && items[current]?.href) { 396 e.preventDefault(); 397 window.location.href = items[current].href; 398 } else if (e.key === "Escape") { 399 close(); 400 } 401 }); 402 403 // Prevent blur when interacting with dropdown 404 dropdown.addEventListener("mousedown", (e) => { 405 e.preventDefault(); 406 }); 407 408 // Show dropdown when input has value and is focused 409 input.addEventListener("focus", () => { if (input.value.trim()) open(); }); 410 input.addEventListener("input", () => { input.value.trim() ? open() : close(); }); 411 412 // Close when clicking outside 413 document.addEventListener("click", (e) => { 414 if (dropdown.dataset.open === "true" && !input.contains(e.target) && !dropdown.contains(e.target)) { 415 close(); 416 } 417 }); 418 }; 419 420 // Initialize for all search inputs 421 [ 422 ["search-input", "suggestions-dropdown"], 423 ["navbar-search-input", "navbar-suggestions-dropdown"], 424 ["mobile-navbar-search-input", "mobile-navbar-suggestions-dropdown"], 425 ["modal-search-input", "modal-suggestions-dropdown"] 426 ].forEach(([inputId, dropdownId]) => { 427 setupSuggestionNav(document.getElementById(inputId), document.getElementById(dropdownId)); 428 }); 429 430 // Open dropdown when HTMX loads results 431 document.body.addEventListener("htmx:afterSwap", (e) => { 432 const dropdown = e.target; 433 if (dropdown?.id?.includes("suggestions-dropdown") && dropdown.querySelector(".suggestion-item")) { 434 dropdown.dataset.open = "true"; 435 } 436 }); 437 438 // ============================================ 439 // Navbar Search Modal (Command Palette Style) 440 // ============================================ 441 const searchModal = document.getElementById("search-modal"); 442 const searchModalPanel = document.getElementById("search-modal-panel"); 443 const searchModalBackdrop = searchModal?.querySelector("[data-search-modal-backdrop]"); 444 let searchModalLastActive = null; 445 446 const isSearchModalOpen = () => searchModal && searchModal.dataset.open === "true"; 447 448 const openSearchModal = () => { 449 if (!searchModal || !searchModalPanel) return; 450 if (isSearchModalOpen()) return; 451 452 searchModalLastActive = document.activeElement; 453 searchModal.dataset.open = "true"; 454 searchModal.setAttribute("aria-hidden", "false"); 455 456 searchModal.classList.remove("hidden"); 457 document.body.classList.add("overflow-hidden"); 458 459 // Kick transitions 460 requestAnimationFrame(() => { 461 if (searchModalBackdrop) { 462 searchModalBackdrop.classList.remove("opacity-0"); 463 } 464 searchModalPanel.classList.remove("opacity-0", "scale-95"); 465 }); 466 467 // Ensure HTMX has processed the modal's hx-* attributes 468 if (window.htmx && typeof window.htmx.process === "function") { 469 window.htmx.process(searchModal); 470 } 471 472 const input = document.getElementById("modal-search-input"); 473 if (input) { 474 // Allow the DOM to paint before focusing 475 setTimeout(() => input.focus(), 0); 476 } 477 }; 478 479 const closeSearchModal = () => { 480 if (!searchModal || !searchModalPanel) return; 481 if (!isSearchModalOpen()) return; 482 483 searchModal.dataset.open = "false"; 484 searchModal.setAttribute("aria-hidden", "true"); 485 486 if (searchModalBackdrop) { 487 searchModalBackdrop.classList.add("opacity-0"); 488 } 489 searchModalPanel.classList.add("opacity-0", "scale-95"); 490 491 // Let transition finish before hiding 492 setTimeout(() => { 493 searchModal.classList.add("hidden"); 494 document.body.classList.remove("overflow-hidden"); 495 496 const previous = searchModalLastActive; 497 searchModalLastActive = null; 498 if (previous && typeof previous.focus === "function") { 499 previous.focus(); 500 } 501 }, 160); 502 }; 503 504 if (searchModal) { 505 document.querySelectorAll("[data-open-search-modal]").forEach((btn) => { 506 btn.addEventListener("click", (e) => { 507 e.preventDefault(); 508 openSearchModal(); 509 }); 510 }); 511 512 // Click outside the dialog closes 513 searchModal.addEventListener("click", (e) => { 514 if (!isSearchModalOpen()) return; 515 if (!searchModalPanel) return; 516 517 const clickedInsidePanel = searchModalPanel.contains(e.target); 518 if (!clickedInsidePanel) { 519 e.preventDefault(); 520 e.stopPropagation(); 521 closeSearchModal(); 522 } 523 }); 524 525 // Prevent clicks inside the panel from reaching the modal container 526 searchModalPanel.addEventListener("click", (e) => { 527 e.stopPropagation(); 528 }); 529 530 // Basic focus trap inside dialog 531 searchModal.addEventListener("keydown", (e) => { 532 if (!isSearchModalOpen()) return; 533 if (e.key === "Escape") { 534 e.preventDefault(); 535 closeSearchModal(); 536 return; 537 } 538 if (e.key !== "Tab") return; 539 540 const focusables = searchModalPanel.querySelectorAll( 541 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not
541([tabindex="-1"])' 542 ); 543 if (!focusables.length) return; 544 545 const first = focusables[0]; 546 const last = focusables[focusables.length - 1]; 547 const active = document.activeElement; 548 549 if (e.shiftKey && active === first) { 550 e.preventDefault(); 551 last.focus(); 552 } else if (!e.shiftKey && active === last) { 553 e.preventDefault(); 554 first.focus(); 555 } 556 }); 557 } 558 559 // ============================================ 560 // Search Placeholder Animation 561 // ============================================ 562 const animatePlaceholder = (input, texts, options = {}) => { 563 if (!input) return; 564 565 const { pauseOnFocus = true } = options; 566 567 let textIndex = 0; 568 let charIndex = 0; 569 let isDeleting = false; 570 let typeSpeed = 100; 571 572 const type = () => { 573 // Stop if input is focused 574 if (pauseOnFocus && document.activeElement === input) { 575 setTimeout(type, 1000); 576 return; 577 } 578 579 const currentText = texts[textIndex]; 580 581 if (isDeleting) { 582 input.setAttribute("placeholder", currentText.substring(0, charIndex - 1)); 583 charIndex--; 584 typeSpeed = 50; 585 } else { 586 input.setAttribute("placeholder", currentText.substring(0, charIndex + 1)); 587 charIndex++; 588 typeSpeed = 100; 589 } 590 591 if (!isDeleting && charIndex === currentText.length) { 592 isDeleting = true; 593 typeSpeed = 2000; // Pause at end 594 } else if (isDeleting && charIndex === 0) { 595 isDeleting = false; 596 textIndex = (textIndex + 1) % texts.length; 597 typeSpeed = 500; // Pause before typing next 598 } 599 600 setTimeout(type, typeSpeed); 601 }; 602 603 type(); 604 }; 605 606 // Initialize placeholder animation for main search 607 const mainSearchInput = document.getElementById("search-input"); 608 if (mainSearchInput) { 609 animatePlaceholder(mainSearchInput, [ 610 "Search Django packages...", 611 'Try "authentication"', 612 'Try "cms"', 613 'Try "api"', 614 'Try "rest"', 615 ]); 616 } 617 618 // Initialize placeholder animation for modal search 619 const modalSearchInput = document.getElementById("modal-search-input"); 620 if (modalSearchInput) { 621 animatePlaceholder(modalSearchInput, [ 622 "Search Django packages...", 623 'Try "authentication"', 624 'Try "cms"', 625 'Try "api"', 626 'Try "rest"', 627 ], { pauseOnFocus: false }); 628 } 629 630 // Initialize placeholder animation for navbar search 631 const navbarSearchInput = document.getElementById("navbar-search-input"); 632 if (navbarSearchInput) { 633 animatePlaceholder(navbarSearchInput, ["Search packages...", 'Try "auth"', 'Try "rest"', 'Try "admin"']); 634 } 635 636 // Initialize placeholder animation for navbar search - mobile 637 const mobileNavbarSearchInput = document.getElementById("mobile-navbar-search-input"); 638 if (mobileNavbarSearchInput) { 639 animatePlaceholder(mobileNavbarSearchInput, ["Search packages...", 'Try "auth"', 'Try "rest"', 'Try "admin"']); 640 } 641 642 // ============================================ 643 // Global Keyboard Shortcuts 644 // ============================================ 645 document.addEventListener("keydown", (e) => { 646 // CMD+/ or CTRL+/ to focus search 647 if ((e.metaKey || e.ctrlKey) && e.key === "/") { 648 e.preventDefault(); 649 if (searchModal) { 650 openSearchModal(); 651 return; 652 } 653 const searchInput = 654 document.getElementById("search-input") || document.getElementById("navbar-search-input"); 655 if (searchInput) searchInput.focus(); 656 } 657 658 // Just / to focus search (if not already in input) 659 if (e.key === "/" && !["INPUT", "TEXTAREA"].includes(document.activeElement.tagName)) { 660 e.preventDefault(); 661 if (searchModal) { 662 openSearchModal(); 663 return; 664 } 665 const searchInput = 666 document.getElementById("search-input") || document.getElementById("navbar-search-input"); 667 if (searchInput) searchInput.focus(); 668 } 669 670 // Escape to unfocus input 671 if (e.key === "Escape" && searchModal && isSearchModalOpen()) { 672 e.preventDefault(); 673 closeSearchModal(); 674 return; 675 } 676 if (e.key === "Escape" && ["INPUT", "TEXTAREA"].includes(document.activeElement.tagName)) { 677 document.activeElement.blur(); 678 } 679 }); 680 681 // ============================================ 682 // Copy Badge Button (Package Detail Page) 683 // ============================================ 684 const copyBadgeBtn = document.getElementById("copy-badge-btn"); 685 if (copyBadgeBtn) { 686 const copyText = document.getElementById("copy-text"); 687 const copyIcon = document.getElementById("copy-icon"); 688 const originalText = copyText ? copyText.textContent : ""; 689 690 copyBadgeBtn.addEventListener("click", function () { 691 const badgeText = this.previousElementSibling.textContent; 692 navigator.clipboard 693 .writeText(badgeText) 694 .then(() => { 695 if (copyText) copyText.textContent = "Copied!"; 696 if (copyIcon) copyIcon.className = "ph-bold ph-check text-base mr-2"; 697 698 setTimeout(() => { 699 if (copyText) copyText.textContent = originalText; 700 if (copyIcon) copyIcon.className = "ph-bold ph-copy text-base mr-2"; 701 }, 2000); 702 }) 703 .catch((err) => { 704 console.error("Failed to copy:", err); 705 }); 706 }); 707 } 708 709 // ============================================ 710 // Toast Notifications Auto-Dismiss 711 // ============================================ 712 const toastContainer = document.getElementById("toast-container"); 713 if (toastContainer) { 714 const messages = toastContainer.querySelectorAll('[role="alert"]');
715 messages.forEach((msg) => { 716 setTimeout(() => { 717 msg.style.transition = "opacity 0.5s ease-out"; 718 msg.style.opacity = "0"; 719 setTimeout(() => msg.remove(), 500); 720 }, 10000); 721 }); 722 } 723 724 // ============================================ 725 // Commit Graph Logic 726 // ============================================ 727 const svg = document.getElementById("commit-svg"); 728 if (svg) { 729 const commitDataStr = svg.dataset.commits; 730 if (commitDataStr) { 731 const commitData = commitDataStr.split(",").map(Number); 732 const pathLine = document.getElementById("commit-line"); 733 const pathArea = document.getElementById("commit-area"); 734 735 if (pathLine && pathArea && commitData.length > 0) { 736 const width = 800; 737 const height = 90; 738 const maxCommits = Math.max(...commitData, 1); // Avoid division by zero 739 const stepX = width / (commitData.length - 1); 740 741 let d = `M 0 ${height - (commitData[0] / maxCommits) * height}`; 742 743 commitData.forEach((val, index) => { 744 const x = index * stepX; 745 const y = height - (val / maxCommits) * height; 746 d += ` L ${x} ${y}`; 747 }); 748 749 pathLine.setAttribute("d", d); 750 pathArea.setAttribute("d", `${d} L ${width} ${height} L 0 ${height} Z`); 751 } 752 } 753 } 754 755 // ============================================ 756 // Add Grid Package Search Dropdown 757 // ============================================ 758 const gridSearchContainer = document.getElementById("search-container"); 759 const gridSearchTarget = document.getElementById("target"); 760 761 if (gridSearchContainer && gridSearchTarget) { 762 document.addEventListener("click", (e) => { 763 if (!gridSearchContainer.contains(e.target)) { 764 gridSearchTarget.innerHTML = ""; 765 } 766 }); 767 768 gridSearchContainer.addEventListener("focusout", (e) => { 769 if (e.relatedTarget && !gridSearchContainer.contains(e.relatedTarget)) { 770 gridSearchTarget.innerHTML = ""; 771 } 772 }); 773 } 774});
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.