PageSourceSearch

https://djangopackages.org/static/js/app.js

js djangopackages.org collected 2026-10-02 04:15:38 UTC 29,853 bytes, 774 lines download raw bytes

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.